diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java index 3d203fc15..c13d74fa8 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java @@ -6,6 +6,7 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -14,6 +15,8 @@ import in.koreatech.koin.domain.bus.dto.BusScheduleResponse; import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusRoutesResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusTimetableResponse; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; import in.koreatech.koin.domain.bus.model.BusTimetable; import in.koreatech.koin.domain.bus.model.enums.BusRouteType; @@ -94,6 +97,25 @@ ResponseEntity> getSearchTimetable( @GetMapping("/courses") ResponseEntity> getBusCourses(); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + } + ) + @Operation(summary = "학교버스 노선 조회") + @GetMapping("/courses/shuttle") + ResponseEntity getShuttleBusRoutes(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "학교버스 특정 노선 시간표 조회", description = "id: 노선 id 값 (get /bus/courses/shuttle response 참조)") + @GetMapping("/timetable/shuttle/{id}") + ResponseEntity getShuttleBusTimetable(@PathVariable String id); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java index b2ed917ba..bfa8bd567 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java @@ -7,6 +7,7 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -17,6 +18,8 @@ import in.koreatech.koin.domain.bus.dto.BusScheduleResponse; import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusRoutesResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusTimetableResponse; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; import in.koreatech.koin.domain.bus.model.BusTimetable; import in.koreatech.koin.domain.bus.model.enums.BusRouteType; @@ -24,6 +27,7 @@ import in.koreatech.koin.domain.bus.model.enums.BusType; import in.koreatech.koin.domain.bus.model.enums.CityBusDirection; import in.koreatech.koin.domain.bus.service.BusService; +import in.koreatech.koin.domain.bus.service.ShuttleBusService; import lombok.RequiredArgsConstructor; @RestController @@ -32,6 +36,7 @@ public class BusController implements BusApi { private final BusService busService; + private final ShuttleBusService shuttleBusService; @GetMapping public ResponseEntity getBusRemainTime( @@ -86,6 +91,16 @@ public ResponseEntity> getSearchTimetable( return ResponseEntity.ok().body(singleBusTimeResponses); } + @GetMapping("/courses/shuttle") + public ResponseEntity getShuttleBusRoutes() { + return ResponseEntity.ok().body(shuttleBusService.getShuttleBusRoutes()); + } + + @GetMapping("/timetable/shuttle/{id}") + public ResponseEntity getShuttleBusTimetable(@PathVariable String id) { + return ResponseEntity.ok().body(shuttleBusService.getShuttleBusTimetable(id)); + } + @GetMapping("/route") public ResponseEntity getBusRouteSchedule( @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusRoutesResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusRoutesResponse.java new file mode 100644 index 000000000..99ea7418b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusRoutesResponse.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.bus.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.enums.ShuttleBusRegion; +import in.koreatech.koin.domain.bus.model.enums.ShuttleRouteType; +import in.koreatech.koin.domain.bus.model.mongo.ShuttleBusRoute; +import in.koreatech.koin.domain.version.dto.VersionMessageResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "셔틀버스 경로 응답") +public record ShuttleBusRoutesResponse( + @Schema(description = "노선 지역 분류 목록") List routeRegions, + @Schema(description = "학기 정보") RouteSemester semesterInfo +) { + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "노선 지역 정보") + public record RouteRegion( + @Schema(description = "지역 이름", example = "천안") + String region, + + @Schema(description = "해당 지역의 경로 목록") + List routes + ) { + } + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "노선 세부 정보") + public record RouteName( + @Schema(description = "노선 ID", example = "675013f9465776d6265ddfdb") String id, + @Schema(description = "노선 종류", example = "주말") String type, + @Schema(description = "노선 이름", example = "대학원") String routeName, + @Schema(description = "노선 부가 이름", example = "토요일") String subName + ) { + } + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "학기 정보") + public record RouteSemester( + @Schema(description = "학기 이름", example = "정규학기") String name, + @Schema(description = "학기 시작 날짜", example = "2024-09-02") String from, + @Schema(description = "학기 종료 날짜", example = "2024-12-20") String to + ) { + } + + public static ShuttleBusRoutesResponse of(List shuttleBusRoutes, + VersionMessageResponse versionMessageResponse) { + List categories = mapCategories(shuttleBusRoutes); + String[] term = versionMessageResponse.content().split("~"); + RouteSemester routeSemester = new RouteSemester(versionMessageResponse.title(), term[0].trim(), term[1].trim()); + return new ShuttleBusRoutesResponse(categories, routeSemester); + } + + private static List mapCategories(List shuttleBusRoutes) { + return shuttleBusRoutes.stream() + .collect(Collectors.groupingBy(ShuttleBusRoute::getRegion)) + .entrySet().stream() + .map(entry -> new RouteRegion(entry.getKey().getLabel(), mapRouteNames(entry.getValue()))) + .sorted(Comparator.comparingInt(o -> ShuttleBusRegion.getOrdinalByLabel(o.region()))) + .toList(); + } + + private static List mapRouteNames(List routes) { + return routes.stream() + .map(route -> new RouteName(route.getId(), route.getRouteType().getLabel(), route.getRouteName(), + route.getSubName())) + .sorted(Comparator.comparingInt(o -> ShuttleRouteType.getOrdinalByLabel(o.type()))) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusTimetableResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusTimetableResponse.java new file mode 100644 index 000000000..d655df2c9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusTimetableResponse.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.bus.dto; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.mongo.ShuttleBusRoute; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "셔틀버스 노선 응답") +public record ShuttleBusTimetableResponse( + @Schema(description = "노선 ID", example = "675013f9465776d6265ddfdb") + String id, + + @Schema(description = "지역 이름", example = "천안") + String region, + + @Schema(description = "노선 타입", example = "순환") + String routeType, + + @Schema(description = "노선 이름", example = "천안 셔틀") + String routeName, + + @Schema(description = "노선 부가 이름", example = "null") + String subName, + + @Schema(description = "정류장 정보 목록") + List nodeInfo, + + @Schema(description = "회차 정보 목록") + List routeInfo +) { + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "정류장 정보") + public record NodeInfoResponse( + @Schema(description = "정류장 이름", example = "캠퍼스 정문") + String name, + + @Schema(description = "정류장 세부 정보", example = "정문 앞 정류장") + String detail + ) { + } + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "노선 정보") + public record RouteInfoResponse( + @Schema(description = "노선 이름", example = "1회") + String name, + + @Schema(description = "노선 세부 정보", example = "등교") + String detail, + + @Schema(description = "도착 시간 목록", example = "[\"08:00\", \"09:00\"]") + List arrivalTime + ) { + } + + public static ShuttleBusTimetableResponse from(ShuttleBusRoute shuttleBusRoute) { + List nodeInfoResponses = shuttleBusRoute.getNodeInfo().stream() + .map(node -> new NodeInfoResponse(node.getName(), node.getDetail())) + .toList(); + List routeInfoResponses = shuttleBusRoute.getRouteInfo().stream() + .map(route -> new RouteInfoResponse(route.getName(), route.getDetail(), route.getArrivalTime())) + .toList(); + return new ShuttleBusTimetableResponse( + shuttleBusRoute.getId(), + shuttleBusRoute.getRegion().getLabel(), + shuttleBusRoute.getRouteType().getLabel(), + shuttleBusRoute.getRouteName(), + shuttleBusRoute.getSubName(), + nodeInfoResponses, + routeInfoResponses + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRegionException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRegionException.java new file mode 100644 index 000000000..03b9f3ebd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRegionException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class BusIllegalRegionException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "버스 지역 구분이 잘못되었습니다."; + + public BusIllegalRegionException(String message) { + super(message); + } + + public BusIllegalRegionException(String message, String detail) { + super(message, detail); + } + + public static BusIllegalStationException withDetail(String detail) { + return new BusIllegalStationException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRouteTypeException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRouteTypeException.java new file mode 100644 index 000000000..ab007e07b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRouteTypeException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class BusIllegalRouteTypeException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "버스 노선 구분이 잘못되었습니다."; + + public BusIllegalRouteTypeException(String message) { + super(message); + } + + public BusIllegalRouteTypeException(String message, String detail) { + super(message, detail); + } + + public static BusIllegalStationException withDetail(String detail) { + return new BusIllegalStationException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleBusRegion.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleBusRegion.java new file mode 100644 index 000000000..758d253e5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleBusRegion.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import in.koreatech.koin.domain.bus.exception.BusIllegalRegionException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ShuttleBusRegion { + CHEONAN_ASAN("천안·아산"), + CHEONGJU("청주"), + SEOUL("서울"), + DAEJEON_SEJONG("대전·세종"), + ; + + private final String label; + + public static int getOrdinalByLabel(String label) { + for (ShuttleBusRegion region : ShuttleBusRegion.values()) { + if (region.getLabel().equals(label)) { + return region.ordinal(); + } + } + throw BusIllegalRegionException.withDetail("displayName: " + label); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleRouteType.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleRouteType.java new file mode 100644 index 000000000..8b41f016e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleRouteType.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import in.koreatech.koin.domain.bus.exception.BusIllegalRouteTypeException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ShuttleRouteType { + SHUTTLE("순환"), + WEEKDAYS("주중"), + WEEKEND("주말"), + ; + + private final String label; + + public static int getOrdinalByLabel(String label) { + for (ShuttleRouteType shuttleRouteType : ShuttleRouteType.values()) { + if (shuttleRouteType.getLabel().equals(label)) { + return shuttleRouteType.ordinal(); + } + } + throw BusIllegalRouteTypeException.withDetail("displayName: " + label); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/mongo/ShuttleBusRoute.java b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/ShuttleBusRoute.java new file mode 100644 index 000000000..d13e68730 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/ShuttleBusRoute.java @@ -0,0 +1,72 @@ +package in.koreatech.koin.domain.bus.model.mongo; + +import static lombok.AccessLevel.PROTECTED; + +import java.util.List; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import in.koreatech.koin.domain.bus.model.enums.ShuttleBusRegion; +import in.koreatech.koin.domain.bus.model.enums.ShuttleRouteType; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Document(collection = "shuttlebus_timetables") +public class ShuttleBusRoute { + + @Id + private String id; + + @Field("semester_type") + private String semesterType; + + @Field("region") + private ShuttleBusRegion region; + + @Field("route_type") + private ShuttleRouteType routeType; + + @Field("route_name") + private String routeName; + + @Field("sub_name") + private String subName; + + @Field("node_info") + private List nodeInfo; + + @Field("route_info") + private List routeInfo; + + @Getter + @NoArgsConstructor(access = PROTECTED) + public static class NodeInfo { + + @Field("name") + private String name; + + @Field("detail") + private String detail; + } + + @Getter + @NoArgsConstructor(access = PROTECTED) + public static class RouteInfo { + + @Field("name") + private String name; + + @Field("detail") + private String detail; + + @Field("running_days") + private List runningDays; + + @Field("arrival_time") + private List arrivalTime; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/ShuttleBusRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/ShuttleBusRepository.java new file mode 100644 index 000000000..2d60c4587 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/ShuttleBusRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.bus.repository; + +import java.util.List; +import java.util.Optional; + +import org.bson.types.ObjectId; +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.bus.exception.BusNotFoundException; +import in.koreatech.koin.domain.bus.model.mongo.ShuttleBusRoute; + +public interface ShuttleBusRepository extends Repository { + + List findBySemesterType(String semesterType); + + Optional findById(String id); + + default ShuttleBusRoute getById(String id) { + return findById(id).orElseThrow( + () -> BusNotFoundException.withDetail("id: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/ShuttleBusService.java b/src/main/java/in/koreatech/koin/domain/bus/service/ShuttleBusService.java new file mode 100644 index 000000000..731bc729d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/service/ShuttleBusService.java @@ -0,0 +1,34 @@ +package in.koreatech.koin.domain.bus.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.bus.dto.ShuttleBusRoutesResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusTimetableResponse; +import in.koreatech.koin.domain.bus.model.mongo.ShuttleBusRoute; +import in.koreatech.koin.domain.bus.repository.ShuttleBusRepository; +import in.koreatech.koin.domain.version.dto.VersionMessageResponse; +import in.koreatech.koin.domain.version.service.VersionService; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ShuttleBusService { + + private final ShuttleBusRepository shuttleBusRepository; + private final VersionService versionService; + + public ShuttleBusRoutesResponse getShuttleBusRoutes() { + VersionMessageResponse version = versionService.getVersionWithMessage("shuttle_bus_timetable"); + List shuttleBusRoutes = shuttleBusRepository.findBySemesterType(version.title()); + return ShuttleBusRoutesResponse.of(shuttleBusRoutes, version); + } + + public ShuttleBusTimetableResponse getShuttleBusTimetable(String id) { + ShuttleBusRoute shuttleBusRoute = shuttleBusRepository.getById(id); + return ShuttleBusTimetableResponse.from(shuttleBusRoute); + } +}