Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<BaseResponse<CompletedRouteListResponse>> 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<BaseResponse<CompletedRouteDetailResponse>> 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<BaseResponse<CompletedRouteSummaryResponse>> 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<Void> deleteRoute(@PathVariable("routeId") Long routeId, @RequestHeader("X-USER-ID") Long userId) {
routeCompletedQueryService.deleteRoute(routeId, userId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -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

) {}
Original file line number Diff line number Diff line change
@@ -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<CompletedRouteItemResponse> items
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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<CompletedRouteSummaryResponse> routes,

@Schema(description = "다음 페이지 존재 여부", example = "true")
boolean hasNext
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
5 changes: 5 additions & 0 deletions src/main/java/com/earseo/route/entity/Route.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/earseo/route/repository/RouteRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -12,4 +16,23 @@ public interface RouteRepository extends JpaRepository<Route, Long> {

@EntityGraph(attributePaths = "items")
Optional<Route> findWithItemsByMemberIdAndStatus(Long memberId, RouteStatus status);

Page<Route> 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<Route> findCompletedRouteDetail(
@Param("routeId") Long routeId,
@Param("memberId") Long memberId
);
}
Original file line number Diff line number Diff line change
@@ -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<Route> slice = routeRepository.findByMemberIdAndStatusOrderByCreatedAtDesc(
memberId,
RouteStatus.COMPLETED,
pageable
);

List<CompletedRouteSummaryResponse> 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<RouteItem> 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);
}
}
Loading