diff --git a/src/main/java/com/earseo/route/controller/RouteCompletedController.java b/src/main/java/com/earseo/route/controller/RouteCompletedController.java new file mode 100644 index 0000000..ab86f39 --- /dev/null +++ b/src/main/java/com/earseo/route/controller/RouteCompletedController.java @@ -0,0 +1,153 @@ +package com.earseo.route.controller; + +import com.earseo.route.common.BaseResponse; +import com.earseo.route.dto.request.ModifyCompletedRouteRequest; +import com.earseo.route.dto.response.CompletedRouteDetailResponse; +import com.earseo.route.dto.response.CompletedRouteListResponse; +import com.earseo.route.dto.response.CompletedRouteSummaryResponse; +import com.earseo.route.service.RouteCompletedQueryService; +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.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/user/route") +@RequiredArgsConstructor +public class RouteCompletedController { + + private final RouteCompletedQueryService routeCompletedQueryService; + + @Operation( + summary = "[완료된 경로 기록 관리] 완료된 경로 리스트 조회", + description = """ + 정상 종료(COMPLETED)된 경로를 최신 생성순(createdAt DESC)으로 조회합니다. + 무한 스크롤을 위해 page/size 기반 페이징을 사용합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "완료된 경로 리스트 조회 성공", + content = @Content(schema = @Schema(implementation = CompletedRouteListResponse.class)) + ) + }) + @GetMapping("/completed") + public ResponseEntity> getCompletedRoutes (@RequestHeader("X-USER-ID") Long userId, + @PageableDefault(size = 10, page = 0) Pageable pageable) { + return ResponseEntity.ok(BaseResponse.ok(routeCompletedQueryService.getCompletedRoutes(userId, pageable))); + } + + @Operation( + summary = "[완료된 경로 기록 관리] 완료된 경로 상세정보 조회", + description = """ + 완료된 경로의 상세정보를 사용자 ID, 완료된 경로의 ID 기반으로 조회합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "완료된 경로 상세정보 조회 성공", + content = @Content(schema = @Schema(implementation = CompletedRouteDetailResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "사용자 정보와 완료된 경로 검증 실패 및 조회 실패", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject( + value = """ + { + "status": "RUT002", + "message": "존재하지 않는 경로입니다.", + "data": null + } + """ + ) + ) + ) + }) + @GetMapping("/completed/detail/{routeId}") + public ResponseEntity> getCompletedRouteDetail(@RequestHeader("X-USER-ID") Long userId, + @PathVariable("routeId") Long routeId) { + return ResponseEntity.ok(BaseResponse.ok(routeCompletedQueryService.getCompletedRouteDetail(userId, routeId))); + } + + @Operation( + summary = "[완료된 경로 기록 관리] 완료된 경로 이름 변경", + description = """ + 완료된 경로의 이름을 변경합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "완료된 경로 이름 변경 성공", + content = @Content(schema = @Schema(implementation = CompletedRouteSummaryResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "사용자 정보와 완료된 경로 검증 실패 및 조회 실패", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject( + value = """ + { + "status": "RUT002", + "message": "존재하지 않는 경로입니다.", + "data": null + } + """ + ) + ) + ) + }) + @PutMapping("/completed/{routeId}") + public ResponseEntity> modifyCompletedRouteName(@Valid @RequestBody ModifyCompletedRouteRequest modifyCompletedRoute, + @RequestHeader("X-USER-ID") Long userId, @PathVariable("routeId") Long routeId) { + return ResponseEntity.ok(BaseResponse.ok(routeCompletedQueryService.modifyCompletedRouteName(modifyCompletedRoute, userId, routeId))); + } + + @Operation( + summary = "[완료된 경로 기록 관리] 완료된 경로 삭제", + description = """ + 완료된 경로를 삭제합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "완료된 경로 삭제 성공" + ), + @ApiResponse( + responseCode = "404", + description = "사용자 정보와 완료된 경로 검증 실패 및 조회 실패", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject( + value = """ + { + "status": "RUT002", + "message": "존재하지 않는 경로입니다.", + "data": null + } + """ + ) + ) + ) + }) + @DeleteMapping("/completed/{routeId}") + public ResponseEntity deleteRoute(@PathVariable("routeId") Long routeId, @RequestHeader("X-USER-ID") Long userId) { + routeCompletedQueryService.deleteRoute(routeId, userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/earseo/route/dto/request/ModifyCompletedRouteRequest.java b/src/main/java/com/earseo/route/dto/request/ModifyCompletedRouteRequest.java new file mode 100644 index 0000000..80a3790 --- /dev/null +++ b/src/main/java/com/earseo/route/dto/request/ModifyCompletedRouteRequest.java @@ -0,0 +1,13 @@ +package com.earseo.route.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "완료된 경로 수정 요청 DTO") +public record ModifyCompletedRouteRequest( + @NotBlank(message = "경로의 이름은 필수입니다") + @Schema(description = "변경할 완료된 경로 이름", example = "나의 여행 1") + String name + +) {} diff --git a/src/main/java/com/earseo/route/dto/response/CompletedRouteDetailResponse.java b/src/main/java/com/earseo/route/dto/response/CompletedRouteDetailResponse.java new file mode 100644 index 0000000..0eed95b --- /dev/null +++ b/src/main/java/com/earseo/route/dto/response/CompletedRouteDetailResponse.java @@ -0,0 +1,15 @@ +package com.earseo.route.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "완료된 경로 상세정보 응답") +public record CompletedRouteDetailResponse( + @Schema(description = "완료된 경로 요약 정보") + CompletedRouteSummaryResponse route, + + @Schema(description = "완료된 경로의 관광지 및 이야기 스팟 리스트") + List items +) { +} diff --git a/src/main/java/com/earseo/route/dto/response/CompletedRouteItemResponse.java b/src/main/java/com/earseo/route/dto/response/CompletedRouteItemResponse.java new file mode 100644 index 0000000..d3c5742 --- /dev/null +++ b/src/main/java/com/earseo/route/dto/response/CompletedRouteItemResponse.java @@ -0,0 +1,21 @@ +package com.earseo.route.dto.response; + + +import com.earseo.route.entity.RouteRefType; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "완료된 경로를 구성하는 개별 지점(SIGHT / STORY_SPOT)의 정보") +public record CompletedRouteItemResponse( + @Schema(description = "아이템 타입(SIGHT / STORY_SPOT)", example = "SIGHT") + RouteRefType itemType, + + @Schema(description = "참조 ID(관광지 / 스토리 스팟의 ID", example = "12") + String itemId, + + @Schema(description = "노출용 이름", example = "경복궁") + String itemName, + + @Schema(description = "대표 이미지 URL", example = "https://cdn.example.com/image/spot/1.jpg") + String itemImageUrl +) { +} diff --git a/src/main/java/com/earseo/route/dto/response/CompletedRouteListResponse.java b/src/main/java/com/earseo/route/dto/response/CompletedRouteListResponse.java new file mode 100644 index 0000000..b8cc2a5 --- /dev/null +++ b/src/main/java/com/earseo/route/dto/response/CompletedRouteListResponse.java @@ -0,0 +1,15 @@ +package com.earseo.route.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "완료된 경로 리스트 응답") +public record CompletedRouteListResponse( + @Schema(description = "완료된 경로 리스트") + List routes, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) { +} diff --git a/src/main/java/com/earseo/route/dto/response/CompletedRouteSummaryResponse.java b/src/main/java/com/earseo/route/dto/response/CompletedRouteSummaryResponse.java new file mode 100644 index 0000000..93922ce --- /dev/null +++ b/src/main/java/com/earseo/route/dto/response/CompletedRouteSummaryResponse.java @@ -0,0 +1,16 @@ +package com.earseo.route.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "완료된 경로 리스트 요약 정보") +public record CompletedRouteSummaryResponse( + @Schema(description = "경로 ID", example = "12") + Long routeId, + + @Schema(description = "경로 이름", example = "경복궁-종묘") + String name, + + @Schema(description = "완료 날짜 (yyyy.MM.dd)", example = "2025.12.10") + String completedAt +) { +} diff --git a/src/main/java/com/earseo/route/entity/Route.java b/src/main/java/com/earseo/route/entity/Route.java index b1a0e27..2b81bed 100644 --- a/src/main/java/com/earseo/route/entity/Route.java +++ b/src/main/java/com/earseo/route/entity/Route.java @@ -84,4 +84,9 @@ protected void onCreate() { protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } + + public void modifyName(String name){ + this.updatedAt = LocalDateTime.now(); + this.name = name; + } } diff --git a/src/main/java/com/earseo/route/repository/RouteRepository.java b/src/main/java/com/earseo/route/repository/RouteRepository.java index ac7e56c..818c535 100644 --- a/src/main/java/com/earseo/route/repository/RouteRepository.java +++ b/src/main/java/com/earseo/route/repository/RouteRepository.java @@ -3,7 +3,11 @@ import com.earseo.route.entity.Route; import com.earseo.route.entity.RouteStatus; import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -12,4 +16,23 @@ public interface RouteRepository extends JpaRepository { @EntityGraph(attributePaths = "items") Optional findWithItemsByMemberIdAndStatus(Long memberId, RouteStatus status); + + Page findByMemberIdAndStatusOrderByCreatedAtDesc( + Long memberId, + RouteStatus status, + Pageable pageable + ); + + @Query(""" + select distinct r + from Route r + left join fetch r.items ri + where r.id = :routeId + and r.memberId = :memberId + order by ri.createdAt asc + """) + Optional findCompletedRouteDetail( + @Param("routeId") Long routeId, + @Param("memberId") Long memberId + ); } diff --git a/src/main/java/com/earseo/route/service/RouteCompletedQueryService.java b/src/main/java/com/earseo/route/service/RouteCompletedQueryService.java new file mode 100644 index 0000000..7accfd4 --- /dev/null +++ b/src/main/java/com/earseo/route/service/RouteCompletedQueryService.java @@ -0,0 +1,90 @@ +package com.earseo.route.service; + +import com.earseo.route.common.exception.BaseException; +import com.earseo.route.common.exception.RouteError; +import com.earseo.route.dto.request.ModifyCompletedRouteRequest; +import com.earseo.route.dto.response.CompletedRouteDetailResponse; +import com.earseo.route.dto.response.CompletedRouteItemResponse; +import com.earseo.route.dto.response.CompletedRouteListResponse; +import com.earseo.route.dto.response.CompletedRouteSummaryResponse; +import com.earseo.route.entity.Route; +import com.earseo.route.entity.RouteItem; +import com.earseo.route.entity.RouteStatus; +import com.earseo.route.repository.RouteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RouteCompletedQueryService { + + private static final int DEFAULT_PAGE_SIZE = 15; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + + private final RouteRepository routeRepository; + + public CompletedRouteListResponse getCompletedRoutes(Long memberId, Pageable pageable) { + Slice slice = routeRepository.findByMemberIdAndStatusOrderByCreatedAtDesc( + memberId, + RouteStatus.COMPLETED, + pageable + ); + + List routes = slice.getContent().stream().map(this::toResponse).toList(); + + return new CompletedRouteListResponse(routes, slice.hasNext()); + } + + private CompletedRouteSummaryResponse toResponse(Route route) { + return new CompletedRouteSummaryResponse( + route.getId(), + route.getName(), + route.getCreatedAt().format(DATE_FORMATTER) + ); + } + + private CompletedRouteItemResponse toRouteItemResponse(RouteItem routeItem) { + return new CompletedRouteItemResponse( + routeItem.getRefType(), + routeItem.getRefId(), + routeItem.getName(), + routeItem.getImageUrl() + ); + } + + @Transactional + public CompletedRouteDetailResponse getCompletedRouteDetail(Long memberId, Long routeId) { + Route route = routeRepository.findCompletedRouteDetail(memberId, routeId) + .orElseThrow(() -> new BaseException(RouteError.ROUTE_NOT_FOUND)); + + List routeItems = route.getItems(); + + return new CompletedRouteDetailResponse(toResponse(route), routeItems.stream().map(this::toRouteItemResponse).toList()); + } + + @Transactional + public CompletedRouteSummaryResponse modifyCompletedRouteName(ModifyCompletedRouteRequest modifyCompletedRoute, Long memberId, Long routeId) { + Route route = routeRepository.findCompletedRouteDetail(memberId, routeId) + .orElseThrow(() -> new BaseException(RouteError.ROUTE_NOT_FOUND)); + + route.modifyName(modifyCompletedRoute.name()); + routeRepository.save(route); + + return new CompletedRouteSummaryResponse(route.getId(), route.getName(), route.getCreatedAt().format(DATE_FORMATTER)); + } + + @Transactional + public void deleteRoute(Long routeId, Long memberId) { + Route route = routeRepository.findCompletedRouteDetail(memberId, routeId) + .orElseThrow(() -> new BaseException(RouteError.ROUTE_NOT_FOUND)); + + routeRepository.delete(route); + } +}