diff --git a/.env.example b/.env.example index d7f26fc..b4de72f 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,4 @@ NAVER_CLIENT_ID= NAVER_CLIENT_SECRET= AWS_S3_BUCKET= AWS_REGION= +TOUR_SERVICE_KEY= diff --git a/src/main/java/com/daytodo/domain/course/controller/TodayCourseController.java b/src/main/java/com/daytodo/domain/course/controller/TodayCourseController.java index 3266d41..5240fbd 100644 --- a/src/main/java/com/daytodo/domain/course/controller/TodayCourseController.java +++ b/src/main/java/com/daytodo/domain/course/controller/TodayCourseController.java @@ -1,17 +1,21 @@ package com.daytodo.domain.course.controller; -import com.daytodo.domain.course.dto.request.CourseReqDTO; -import com.daytodo.domain.course.dto.response.CourseResDTO; +import com.daytodo.domain.course.dto.request.TodayCourseRequest; +import com.daytodo.domain.course.dto.response.TodayCourseResponse; import com.daytodo.domain.course.service.TodayCourseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Today Course") @RestController @RequiredArgsConstructor @RequestMapping("/courses") @@ -19,28 +23,50 @@ public class TodayCourseController { private final TodayCourseService todayCourseService; - // TODO: JWT 인증 적용 후 @AuthenticationPrincipal 로 교체 + @Operation(summary = "투데이 코스 조회") @GetMapping("/today") - public CourseResDTO.GetTodayCourse getTodayCourse( - @RequestHeader("X-User-Id") Long userId + public TodayCourseResponse.GetTodayCourse getTodayCourse( + @AuthenticationPrincipal Long userId ) { return todayCourseService.getTodayCourse(userId); } + @Operation(summary = "투데이 코스 종료") @PostMapping("/{courseId}/complete") - public CourseResDTO.CompleteCourse completeCourse( - @RequestHeader("X-User-Id") Long userId, + public TodayCourseResponse.CompleteCourse completeCourse( + @AuthenticationPrincipal Long userId, @PathVariable Long courseId ) { return todayCourseService.completeCourse(userId, courseId); } + @Operation(summary = "투데이 추억 사진 저장") @PostMapping("/{courseId}/photos") - public CourseResDTO.SaveMemoryPhotos saveMemoryPhotos( - @RequestHeader("X-User-Id") Long userId, + public TodayCourseResponse.SaveMemoryPhotos saveMemoryPhotos( + @AuthenticationPrincipal Long userId, @PathVariable Long courseId, - @RequestBody CourseReqDTO.SaveMemoryPhotos request + @RequestBody TodayCourseRequest.SaveMemoryPhotos request ) { return todayCourseService.saveMemoryPhotos(userId, courseId, request); } + + @Operation(summary = "투데이 장소 추가") + @PostMapping("/{courseId}/today-places") + public TodayCourseResponse.GetCoursePlaces addPlaceToCourse( + @AuthenticationPrincipal Long userId, + @PathVariable Long courseId, + @RequestBody TodayCourseRequest.AddPlace request + ) { + return todayCourseService.addPlaceToCourse(userId, courseId, request); + } + + @Operation(summary = "투데이 장소 순서 변경") + @PatchMapping("/{courseId}/today-places/order") + public TodayCourseResponse.GetCoursePlaces reorderCoursePlaces( + @AuthenticationPrincipal Long userId, + @PathVariable Long courseId, + @RequestBody TodayCourseRequest.ReorderCoursePlaces request + ) { + return todayCourseService.reorderCoursePlaces(userId, courseId, request); + } } diff --git a/src/main/java/com/daytodo/domain/course/converter/MemoryPhotoConverter.java b/src/main/java/com/daytodo/domain/course/converter/MemoryPhotoConverter.java index 8eec204..d67a046 100644 --- a/src/main/java/com/daytodo/domain/course/converter/MemoryPhotoConverter.java +++ b/src/main/java/com/daytodo/domain/course/converter/MemoryPhotoConverter.java @@ -1,6 +1,6 @@ package com.daytodo.domain.course.converter; -import com.daytodo.domain.course.dto.response.CourseResDTO; +import com.daytodo.domain.course.dto.response.TodayCourseResponse; import com.daytodo.domain.course.entity.Course; import com.daytodo.domain.course.entity.MemoryPhoto; @@ -30,16 +30,16 @@ public static List toMemoryPhotos( .toList(); } - public static CourseResDTO.SaveMemoryPhotos toSaveMemoryPhotos(List memoryPhotos) { - List photos = memoryPhotos.stream() + public static TodayCourseResponse.SaveMemoryPhotos toSaveMemoryPhotos(List memoryPhotos) { + List photos = memoryPhotos.stream() .map(MemoryPhotoConverter::toPhotoItem) .toList(); - return new CourseResDTO.SaveMemoryPhotos(photos.size(), photos); + return new TodayCourseResponse.SaveMemoryPhotos(photos.size(), photos); } - private static CourseResDTO.SaveMemoryPhotos.PhotoItem toPhotoItem(MemoryPhoto memoryPhoto) { - return CourseResDTO.SaveMemoryPhotos.PhotoItem.builder() + private static TodayCourseResponse.SaveMemoryPhotos.PhotoItem toPhotoItem(MemoryPhoto memoryPhoto) { + return TodayCourseResponse.SaveMemoryPhotos.PhotoItem.builder() .memoryPhotoId(memoryPhoto.getId()) .imageUrl(memoryPhoto.getImageUrl()) .photoOrder(memoryPhoto.getPhotoOrder()) diff --git a/src/main/java/com/daytodo/domain/course/converter/TodayCourseConverter.java b/src/main/java/com/daytodo/domain/course/converter/TodayCourseConverter.java index 9fc708c..6917888 100644 --- a/src/main/java/com/daytodo/domain/course/converter/TodayCourseConverter.java +++ b/src/main/java/com/daytodo/domain/course/converter/TodayCourseConverter.java @@ -1,6 +1,6 @@ package com.daytodo.domain.course.converter; -import com.daytodo.domain.course.dto.response.CourseResDTO; +import com.daytodo.domain.course.dto.response.TodayCourseResponse; import com.daytodo.domain.course.entity.Course; import com.daytodo.domain.course.entity.CourseMember; import com.daytodo.domain.course.entity.CoursePlace; @@ -17,17 +17,17 @@ public class TodayCourseConverter { private TodayCourseConverter() { } - public static CourseResDTO.GetTodayCourse toTodayCourse( + public static TodayCourseResponse.GetTodayCourse toTodayCourse( Course course, List members, List coursePlaces ) { if (course == null) { - return new CourseResDTO.GetTodayCourse(null); + return new TodayCourseResponse.GetTodayCourse(null); } - return new CourseResDTO.GetTodayCourse( - CourseResDTO.GetTodayCourse.TodayCourse.builder() + return new TodayCourseResponse.GetTodayCourse( + TodayCourseResponse.GetTodayCourse.TodayCourse.builder() .courseId(course.getCourseId()) .courseName(course.getCourseName()) .members(members.stream().map(TodayCourseConverter::toMemberItem).toList()) @@ -36,27 +36,34 @@ public static CourseResDTO.GetTodayCourse toTodayCourse( ); } - public static CourseResDTO.CompleteCourse toCompleteCourse(Course course) { - return CourseResDTO.CompleteCourse.builder() + // 코스 장소 추가/순서변경 응답: 장소 목록 -> places 응답 + public static TodayCourseResponse.GetCoursePlaces toCoursePlaces(List coursePlaces) { + return new TodayCourseResponse.GetCoursePlaces( + coursePlaces.stream().map(TodayCourseConverter::toPlaceItem).toList() + ); + } + + public static TodayCourseResponse.CompleteCourse toCompleteCourse(Course course) { + return TodayCourseResponse.CompleteCourse.builder() .courseId(course.getCourseId()) .courseStatus(course.getCourseStatus()) .completedAt(course.getCompletedAt()) .build(); } - private static CourseResDTO.GetTodayCourse.MemberItem toMemberItem(CourseMember courseMember) { + private static TodayCourseResponse.GetTodayCourse.MemberItem toMemberItem(CourseMember courseMember) { User user = courseMember.getUser(); - return CourseResDTO.GetTodayCourse.MemberItem.builder() + return TodayCourseResponse.GetTodayCourse.MemberItem.builder() .nickname(user.getNickname()) .profileImageUrl(user.getProfileImageUrl()) .build(); } - private static CourseResDTO.GetTodayCourse.PlaceItem toPlaceItem(CoursePlace coursePlace) { + private static TodayCourseResponse.GetTodayCourse.PlaceItem toPlaceItem(CoursePlace coursePlace) { Place place = coursePlace.getPlace(); - return CourseResDTO.GetTodayCourse.PlaceItem.builder() + return TodayCourseResponse.GetTodayCourse.PlaceItem.builder() .coursePlaceId(coursePlace.getCoursePlaceId()) .placeOrder(coursePlace.getPlaceOrder()) .placeName(place.getPlaceName()) diff --git a/src/main/java/com/daytodo/domain/course/dto/request/CourseReqDTO.java b/src/main/java/com/daytodo/domain/course/dto/request/CourseReqDTO.java deleted file mode 100644 index 267f2cc..0000000 --- a/src/main/java/com/daytodo/domain/course/dto/request/CourseReqDTO.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.daytodo.domain.course.dto.request; - -import java.util.List; - -public class CourseReqDTO { - - /* - * 추억 사진 저장 - * 빈 목록/공백 URL 검증은 서비스에서 수행한다. - * (명세가 요구하는 INVALID_PARAMETER 코드로 응답하기 위해 Bean Validation 을 쓰지 않음) - */ - public record SaveMemoryPhotos( - List imageUrls - ){} -} diff --git a/src/main/java/com/daytodo/domain/course/dto/request/TodayCourseRequest.java b/src/main/java/com/daytodo/domain/course/dto/request/TodayCourseRequest.java new file mode 100644 index 0000000..97a4421 --- /dev/null +++ b/src/main/java/com/daytodo/domain/course/dto/request/TodayCourseRequest.java @@ -0,0 +1,25 @@ +package com.daytodo.domain.course.dto.request; + +import java.util.List; + +public class TodayCourseRequest { + + /* + * 추억 사진 저장 + * 빈 목록/공백 URL 검증은 서비스에서 수행한다. + * (명세가 요구하는 INVALID_PARAMETER 코드로 응답하기 위해 Bean Validation 을 쓰지 않음) + */ + public record SaveMemoryPhotos( + List imageUrls + ){} + + // 코스 장소 추가 : placeId 로 직접 추가. null 검증은 서비스에서 (INVALID_PARAMETER 코드 유지) + public record AddPlace( + Long placeId + ){} + + // 코스 장소 순서 변경 : 배열 순서대로 placeOrder 갱신. 검증은 서비스에서 + public record ReorderCoursePlaces( + List orderedCoursePlaceIds + ){} +} diff --git a/src/main/java/com/daytodo/domain/course/dto/response/CourseResDTO.java b/src/main/java/com/daytodo/domain/course/dto/response/TodayCourseResponse.java similarity index 79% rename from src/main/java/com/daytodo/domain/course/dto/response/CourseResDTO.java rename to src/main/java/com/daytodo/domain/course/dto/response/TodayCourseResponse.java index 6056f5c..dd21da4 100644 --- a/src/main/java/com/daytodo/domain/course/dto/response/CourseResDTO.java +++ b/src/main/java/com/daytodo/domain/course/dto/response/TodayCourseResponse.java @@ -6,7 +6,7 @@ import java.time.LocalDateTime; import java.util.List; -public class CourseResDTO { +public class TodayCourseResponse { // 투데이 코스 조회 (오늘 진행 중인 코스가 없으면 todayCourse 는 null) public record GetTodayCourse( @@ -35,6 +35,12 @@ public record PlaceItem( ){} } + // 코스 장소 추가/순서변경 응답: 추가·변경 후 전체 목록을 순서대로 반환 + // (투데이 조회의 장소 목록과 동일한 형태 재사용: coursePlaceId/placeOrder/placeName/category) + public record GetCoursePlaces( + List places + ){} + // 코스 종료 @Builder public record CompleteCourse( diff --git a/src/main/java/com/daytodo/domain/course/entity/CoursePlace.java b/src/main/java/com/daytodo/domain/course/entity/CoursePlace.java index 188db31..ea2b804 100644 --- a/src/main/java/com/daytodo/domain/course/entity/CoursePlace.java +++ b/src/main/java/com/daytodo/domain/course/entity/CoursePlace.java @@ -44,5 +44,8 @@ public class CoursePlace extends BaseCreatedEntity { @Column(name = "visited_at") private LocalDateTime visitedAt; - + // 코스 장소 순서 변경 + public void changeOrder(int placeOrder) { + this.placeOrder = placeOrder; + } } diff --git a/src/main/java/com/daytodo/domain/course/exception/code/CourseErrorCode.java b/src/main/java/com/daytodo/domain/course/exception/code/CourseErrorCode.java index 49208e9..715faf4 100644 --- a/src/main/java/com/daytodo/domain/course/exception/code/CourseErrorCode.java +++ b/src/main/java/com/daytodo/domain/course/exception/code/CourseErrorCode.java @@ -18,10 +18,11 @@ public enum CourseErrorCode implements BaseErrorCode { PAST_COURSE_DATE(HttpStatus.BAD_REQUEST, "PAST_COURSE_DATE", "오늘 이전 날짜로 코스를 생성할 수 없습니다."), INVALID_PRICE_RANGE(HttpStatus.BAD_REQUEST, "INVALID_PRICE_RANGE", "최대 금액은 최소 금액보다 작을 수 없습니다."), INVITE_CODE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "INVITE_CODE_GENERATION_FAILED", "초대코드를 생성하지 못했습니다."), - // 코스 종료(TDY-007) + + // 코스 종료 INVALID_COURSE_STATUS(HttpStatus.CONFLICT, "INVALID_COURSE_STATUS", "진행 중인 코스만 종료할 수 있습니다."), - // 추억 사진 저장(TDY-008) + // 추억 사진 저장 EMPTY_MEMORY_PHOTO(HttpStatus.BAD_REQUEST, "INVALID_PARAMETER", "저장할 이미지가 없습니다."), COURSE_SAME_DAY_EDIT_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "COURSE_SAME_DAY_EDIT_NOT_ALLOWED", "당일 코스는 수정할 수 없습니다."), @@ -36,6 +37,13 @@ public enum CourseErrorCode implements BaseErrorCode { DUPLICATE_PLACE(HttpStatus.CONFLICT, "DUPLICATE_PLACE", "이미 코스에 추가된 장소입니다."), PLACE_NOT_FOUND(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다."), DUPLICATE_RECOMMENDATION(HttpStatus.CONFLICT, "DUPLICATE_RECOMMENDATION", "이미 추천된 장소입니다."), + + // 코스 장소 추가 + MISSING_PLACE_ID(HttpStatus.BAD_REQUEST, "INVALID_PARAMETER", "placeId가 필요합니다."), + + // 코스 장소 순서 변경 + INVALID_PLACE_ORDER(HttpStatus.BAD_REQUEST, "INVALID_PARAMETER", "순서 정보가 올바르지 않습니다."), + COURSE_PLACE_NOT_FOUND(HttpStatus.NOT_FOUND, "COURSE_PLACE_NOT_FOUND", "코스에 속하지 않는 장소가 포함되어 있습니다."), COURSE_RECOMMENDATION_FAILED(HttpStatus.CONFLICT, "COURSE_RECOMMENDATION_FAILED", "추천 코스를 생성할 수 없습니다."); private final HttpStatus status; diff --git a/src/main/java/com/daytodo/domain/course/service/CourseService.java b/src/main/java/com/daytodo/domain/course/service/CourseService.java index 5e0688e..bbcc534 100644 --- a/src/main/java/com/daytodo/domain/course/service/CourseService.java +++ b/src/main/java/com/daytodo/domain/course/service/CourseService.java @@ -521,29 +521,31 @@ public CourseResponse.CoursePlaceAdded addRecommendationToCourse(Long courseId, throw new ProjectException(CourseErrorCode.RECOMMENDATION_NOT_FOUND); } - // 이미 코스에 추가된 장소인지 중복 확인 - if (coursePlaceRepository.existsByCourse_CourseIdAndPlace_PlaceId(courseId, recommendation.getPlace().getPlaceId())) { - throw new ProjectException(CourseErrorCode.DUPLICATE_PLACE); - } + CoursePlace coursePlace = appendPlaceToCourse(course, recommendation.getPlace(), userId); - // 마지막 장소 순서 구하기 (없으면 0) - Integer maxOrder = coursePlaceRepository.findMaxPlaceOrderByCourse_CourseId(courseId).orElse(0); + return new CourseResponse.CoursePlaceAdded(coursePlace.getCoursePlaceId()); + } - // 추가자(User) 조회 - User addedByUser = userService.getActiveUser(userId); + // 코스 맨 뒤에 장소 추가 (추천담기/투데이추가 공통): 중복확인 + 마지막순서+1 + CoursePlace 생성 + // 투데이 장소추가(TDY-006)는 TodayCourseService 가 이 메서드를 재사용한다. + public CoursePlace appendPlaceToCourse(Course course, Place place, Long userId) { + // 여러 요청이 동시에 장소를 추가해도 placeOrder 가 겹치지 않도록, + // 중복확인~순번계산~저장 구간을 코스 행 쓰기 락으로 직렬화한다. (추억 사진 저장과 동일 패턴) + courseRepository.findByIdForUpdate(course.getCourseId()) + .orElseThrow(() -> new ProjectException(CourseErrorCode.COURSE_NOT_FOUND)); - // Builder를 이용한 CoursePlace 생성 - CoursePlace coursePlace = CoursePlace.builder() + if (coursePlaceRepository.existsByCourse_CourseIdAndPlace_PlaceId(course.getCourseId(), place.getPlaceId())) { + throw new ProjectException(CourseErrorCode.DUPLICATE_PLACE); + } + int nextOrder = coursePlaceRepository.findMaxPlaceOrderByCourse_CourseId(course.getCourseId()).orElse(0) + 1; + User addedBy = userService.getActiveUser(userId); + return coursePlaceRepository.save(CoursePlace.builder() .course(course) - .place(recommendation.getPlace()) - .addedBy(addedByUser) - .placeOrder(maxOrder + 1) + .place(place) + .addedBy(addedBy) + .placeOrder(nextOrder) .coursePlaceStatus(CoursePlaceStatus.PENDING) - .build(); - - coursePlaceRepository.save(coursePlace); - - return new CourseResponse.CoursePlaceAdded(coursePlace.getCoursePlaceId()); + .build()); } @Transactional diff --git a/src/main/java/com/daytodo/domain/course/service/TodayCourseService.java b/src/main/java/com/daytodo/domain/course/service/TodayCourseService.java index 41c70ab..bdc7161 100644 --- a/src/main/java/com/daytodo/domain/course/service/TodayCourseService.java +++ b/src/main/java/com/daytodo/domain/course/service/TodayCourseService.java @@ -2,8 +2,8 @@ import com.daytodo.domain.course.converter.MemoryPhotoConverter; import com.daytodo.domain.course.converter.TodayCourseConverter; -import com.daytodo.domain.course.dto.request.CourseReqDTO; -import com.daytodo.domain.course.dto.response.CourseResDTO; +import com.daytodo.domain.course.dto.request.TodayCourseRequest; +import com.daytodo.domain.course.dto.response.TodayCourseResponse; import com.daytodo.domain.course.entity.Course; import com.daytodo.domain.course.entity.CourseMember; import com.daytodo.domain.course.entity.CoursePlace; @@ -15,13 +15,18 @@ import com.daytodo.domain.course.repository.CoursePlaceRepository; import com.daytodo.domain.course.repository.CourseRepository; import com.daytodo.domain.course.repository.MemoryPhotoRepository; +import com.daytodo.domain.place.entity.Place; +import com.daytodo.domain.place.repository.PlaceRepository; import com.daytodo.global.apiPayload.exception.ProjectException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; @Service @@ -32,13 +37,16 @@ public class TodayCourseService { private final CourseMemberRepository courseMemberRepository; private final CoursePlaceRepository coursePlaceRepository; private final MemoryPhotoRepository memoryPhotoRepository; + private final PlaceRepository placeRepository; + // 코스 장소 추가 시 추천담기와 공통인 append 로직을 재사용한다. + private final CourseService courseService; /* * 투데이 코스 조회 * 오늘 진행 중인 코스가 없으면 예외가 아니라 todayCourse: null 로 응답한다. */ @Transactional(readOnly = true) - public CourseResDTO.GetTodayCourse getTodayCourse(Long userId) { + public TodayCourseResponse.GetTodayCourse getTodayCourse(Long userId) { Optional todayCourse = courseRepository.findMemberCoursesByDateAndStatus( userId, LocalDate.now(), @@ -64,7 +72,7 @@ public CourseResDTO.GetTodayCourse getTodayCourse(Long userId) { * 진행 중(IN_PROGRESS)인 코스만 완료 처리할 수 있다. */ @Transactional - public CourseResDTO.CompleteCourse completeCourse(Long userId, Long courseId) { + public TodayCourseResponse.CompleteCourse completeCourse(Long userId, Long courseId) { Course course = getCourseAsMember(userId, courseId); if (!course.isInProgress()) { @@ -81,10 +89,10 @@ public CourseResDTO.CompleteCourse completeCourse(Long userId, Long courseId) { * diary_id 는 비워둔 채 저장하고, 이후 해당 날짜의 일기가 작성될 때 연결한다. */ @Transactional - public CourseResDTO.SaveMemoryPhotos saveMemoryPhotos( + public TodayCourseResponse.SaveMemoryPhotos saveMemoryPhotos( Long userId, Long courseId, - CourseReqDTO.SaveMemoryPhotos request + TodayCourseRequest.SaveMemoryPhotos request ) { Course course = getCourseAsMember(userId, courseId); @@ -105,7 +113,7 @@ public CourseResDTO.SaveMemoryPhotos saveMemoryPhotos( } // 공백 URL 은 걸러내고, 저장할 이미지가 하나도 없으면 400 으로 응답한다. - private List extractImageUrls(CourseReqDTO.SaveMemoryPhotos request) { + private List extractImageUrls(TodayCourseRequest.SaveMemoryPhotos request) { if (request == null || request.imageUrls() == null) { throw new ProjectException(CourseErrorCode.EMPTY_MEMORY_PHOTO); } @@ -122,6 +130,73 @@ private List extractImageUrls(CourseReqDTO.SaveMemoryPhotos request) { return imageUrls; } + /* + * 코스 장소 추가 + * placeId 로 장소를 코스 맨 뒤에 추가하고, 추가 후 전체 목록을 순서대로 반환한다. + */ + @Transactional + public TodayCourseResponse.GetCoursePlaces addPlaceToCourse( + Long userId, + Long courseId, + TodayCourseRequest.AddPlace request + ) { + if (request == null || request.placeId() == null) { + throw new ProjectException(CourseErrorCode.MISSING_PLACE_ID); + } + + Course course = getCourseAsMember(userId, courseId); + + Place place = placeRepository.findById(request.placeId()) + .orElseThrow(() -> new ProjectException(CourseErrorCode.PLACE_NOT_FOUND)); + + // 중복확인 + 마지막순서+1 + CoursePlace 생성 (추천담기와 공통) + courseService.appendPlaceToCourse(course, place, userId); + + return TodayCourseConverter.toCoursePlaces(coursePlaceRepository.findPlacesByCourseId(courseId)); + } + + /* + * 코스 장소 순서 변경 + * 전달받은 순서대로 placeOrder 를 갱신하고, 변경된 목록을 순서대로 반환한다. + * 순서 충돌을 막기 위해 코스의 전체 장소를 빠짐없이 재배열하는 경우만 허용한다. + */ + @Transactional + public TodayCourseResponse.GetCoursePlaces reorderCoursePlaces( + Long userId, + Long courseId, + TodayCourseRequest.ReorderCoursePlaces request + ) { + List orderedIds = (request == null) ? null : request.orderedCoursePlaceIds(); + if (orderedIds == null || orderedIds.isEmpty() + || orderedIds.stream().anyMatch(Objects::isNull) + || orderedIds.stream().distinct().count() != orderedIds.size()) { + throw new ProjectException(CourseErrorCode.INVALID_PLACE_ORDER); + } + + getCourseAsMember(userId, courseId); + + List coursePlaces = coursePlaceRepository.findByCourse_CourseIdOrderByPlaceOrderAsc(courseId); + if (orderedIds.size() != coursePlaces.size()) { + throw new ProjectException(CourseErrorCode.INVALID_PLACE_ORDER); + } + + Map byId = new HashMap<>(); + for (CoursePlace cp : coursePlaces) { + byId.put(cp.getCoursePlaceId(), cp); + } + + int order = 1; + for (Long coursePlaceId : orderedIds) { + CoursePlace target = byId.get(coursePlaceId); + if (target == null) { + throw new ProjectException(CourseErrorCode.COURSE_PLACE_NOT_FOUND); + } + target.changeOrder(order++); + } + + return TodayCourseConverter.toCoursePlaces(coursePlaceRepository.findPlacesByCourseId(courseId)); + } + private Course getCourseAsMember(Long userId, Long courseId) { Course course = courseRepository.findById(courseId) .orElseThrow(() -> new ProjectException(CourseErrorCode.COURSE_NOT_FOUND)); diff --git a/src/main/java/com/daytodo/domain/place/controller/PlaceController.java b/src/main/java/com/daytodo/domain/place/controller/PlaceController.java index 885f8ac..3e6f522 100644 --- a/src/main/java/com/daytodo/domain/place/controller/PlaceController.java +++ b/src/main/java/com/daytodo/domain/place/controller/PlaceController.java @@ -1,18 +1,27 @@ package com.daytodo.domain.place.controller; import com.daytodo.domain.place.dto.request.PlaceReqDTO; +import com.daytodo.domain.place.dto.response.MagazineResDTO; import com.daytodo.domain.place.dto.response.PlaceResDTO; +import com.daytodo.domain.place.service.MagazineService; import com.daytodo.domain.place.service.PlaceBookmarkService; import com.daytodo.domain.place.service.PlaceSearchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.PathVariable; +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; +@Tag(name = "Place") @RestController @RequiredArgsConstructor @RequestMapping("/places") @@ -20,7 +29,9 @@ public class PlaceController { private final PlaceSearchService placeSearchService; private final PlaceBookmarkService placeBookmarkService; + private final MagazineService magazineService; + @Operation(summary = "장소 검색") @GetMapping("/search") public PlaceResDTO.GetPlaceSearch searchPlaces( @ParameterObject @ModelAttribute @Valid PlaceReqDTO.GetPlaceSearch request @@ -28,12 +39,54 @@ public PlaceResDTO.GetPlaceSearch searchPlaces( return placeSearchService.search(request); } - // TODO: JWT 인증 적용 후 @AuthenticationPrincipal 로 교체 + @Operation(summary = "저장한 매거진 목록 조회") @GetMapping("/bookmarks") public PlaceResDTO.GetBookmarkList getBookmarkList( - @RequestHeader("X-User-Id") Long userId, + @AuthenticationPrincipal Long userId, @ParameterObject @ModelAttribute PlaceReqDTO.GetBookmarkList request ) { return placeBookmarkService.getBookmarkList(userId, request); } + + @Operation(summary = "오늘의 Pick 매거진 목록 조회") + @GetMapping("/magazines") + public MagazineResDTO.GetMagazineList getMagazines( + @AuthenticationPrincipal Long userId + ) { + return magazineService.getMagazineList(userId); + } + + @Operation(summary = "매거진 상세 조회") + @GetMapping("/magazines/{magazineId}") + public MagazineResDTO.GetMagazineDetail getMagazineDetail( + @PathVariable Long magazineId + ) { + return magazineService.getMagazineDetail(magazineId); + } + + @Operation(summary = "매거진 사진 더보기") + @GetMapping("/magazines/{magazineId}/photos") + public MagazineResDTO.GetMagazinePhotos getMagazinePhotos( + @PathVariable Long magazineId + ) { + return magazineService.getMagazinePhotos(magazineId); + } + + @Operation(summary = "매거진 장소 저장") + @PostMapping("/bookmarks") + public PlaceResDTO.CreateBookmark createBookmark( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody PlaceReqDTO.CreateBookmark request + ) { + return placeBookmarkService.createBookmark(userId, request.contentId()); + } + + @Operation(summary = "매거진 장소 저장 해제") + @DeleteMapping("/bookmarks/{bookmarkId}") + public PlaceResDTO.DeleteBookmark deleteBookmark( + @AuthenticationPrincipal Long userId, + @PathVariable Long bookmarkId + ) { + return placeBookmarkService.deleteBookmark(userId, bookmarkId); + } } diff --git a/src/main/java/com/daytodo/domain/place/converter/MagazineConverter.java b/src/main/java/com/daytodo/domain/place/converter/MagazineConverter.java new file mode 100644 index 0000000..74cc3d0 --- /dev/null +++ b/src/main/java/com/daytodo/domain/place/converter/MagazineConverter.java @@ -0,0 +1,185 @@ +package com.daytodo.domain.place.converter; + +import com.daytodo.domain.place.dto.response.MagazineResDTO; +import com.daytodo.domain.place.infra.TourApiResponse; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * KorService2 원본 응답 -> 매거진 응답 변환. + */ +public class MagazineConverter { + + private MagazineConverter() { + } + + private static final String HTML_TAG_PATTERN = "<[^>]*>"; + + // Naver 주소 표기와 동일하게 시/도 축약 (예: 서울특별시 -> 서울) + private static final Map SIDO_SHORT_NAMES = Map.ofEntries( + Map.entry("서울특별시", "서울"), + Map.entry("부산광역시", "부산"), + Map.entry("대구광역시", "대구"), + Map.entry("인천광역시", "인천"), + Map.entry("광주광역시", "광주"), + Map.entry("대전광역시", "대전"), + Map.entry("울산광역시", "울산"), + Map.entry("세종특별자치시", "세종"), + Map.entry("경기도", "경기"), + Map.entry("강원특별자치도", "강원"), + Map.entry("충청북도", "충북"), + Map.entry("충청남도", "충남"), + Map.entry("전북특별자치도", "전북"), + Map.entry("전라남도", "전남"), + Map.entry("경상북도", "경북"), + Map.entry("경상남도", "경남"), + Map.entry("제주특별자치도", "제주") + ); + + // KorService2 contentTypeId -> 표시용 카테고리명 + private static final Map CONTENT_TYPE_NAMES = Map.of( + "12", "관광지", + "14", "문화시설", + "15", "축제공연행사", + "25", "여행코스", + "28", "레포츠", + "32", "숙박", + "38", "쇼핑", + "39", "음식점" + ); + + // ---- 목록 ---- + + public static MagazineResDTO.GetMagazineList.MagazineItem toMagazineItem( + TourApiResponse.AreaItem item, + boolean isAd, + String tagline + ) { + return MagazineResDTO.GetMagazineList.MagazineItem.builder() + .magazineId(parseLong(item.contentid())) + .thumbnailUrl(firstNonBlank(item.firstimage(), item.firstimage2())) + .placeName(removeHtmlTags(item.title())) + .regionName(extractRegionName(item.addr1())) + .tagline(emptyToNull(removeHtmlTags(tagline))) + .isAd(isAd) + .build(); + } + + // ---- 상세 ---- + + public static MagazineResDTO.GetMagazineDetail toDetail( + TourApiResponse.CommonItem common, + TourApiResponse.IntroItem intro, + List images + ) { + return MagazineResDTO.GetMagazineDetail.builder() + .magazineId(parseLong(common.contentid())) + .placeId(parseLong(common.contentid())) + .thumbnailUrl(firstNonBlank(common.firstimage(), common.firstimage2())) + .category(categoryName(common.contenttypeid())) + .placeName(removeHtmlTags(common.title())) + .address(joinAddress(common.addr1(), common.addr2())) + .businessHours(businessHours(intro)) + .phone(emptyToNull(common.tel())) + .content(emptyToNull(removeHtmlTags(common.overview()))) + .photos(toPhotoItems(images).stream().limit(3).toList()) // 미리보기 앞 3장 + .build(); + } + + // ---- 사진 ---- + + public static MagazineResDTO.GetMagazinePhotos toPhotos(List images) { + return new MagazineResDTO.GetMagazinePhotos(toPhotoItems(images)); + } + + public static List toPhotoItems(List images) { + if (images == null) { + return List.of(); + } + // serialnum 은 "2871004_3" 같은 문자열이라 숫자 정렬 불가. + // detailImage2 응답 순서를 그대로 두고 imageOrder 를 1부터 부여한다. + List result = new ArrayList<>(); + int order = 1; + for (TourApiResponse.ImageItem img : images) { + result.add(MagazineResDTO.PhotoItem.builder() + .imageId(emptyToNull(img.serialnum())) + .imageUrl(firstNonBlank(img.originimgurl(), img.smallimageurl())) + .imageOrder(order++) + .build()); + } + return result; + } + + /** KorService2 contentTypeId -> 표시용 카테고리명 (미매핑 시 null). */ + public static String categoryName(String contentTypeId) { + return contentTypeId == null ? null : CONTENT_TYPE_NAMES.get(contentTypeId); + } + + // ---- 헬퍼 ---- + + // contentTypeId 별로 영업시간 필드가 달라 해당 필드를 골라 반환 + private static String businessHours(TourApiResponse.IntroItem intro) { + if (intro == null) { + return null; + } + String value = switch (intro.contenttypeid() == null ? "" : intro.contenttypeid()) { + case "39" -> intro.opentimefood(); + case "12" -> intro.usetime(); + case "14" -> intro.usetimeculture(); + case "28" -> intro.usetimeleports(); + case "38" -> intro.opentime(); + default -> null; + }; + return emptyToNull(removeHtmlTags(value)); + } + + private static String joinAddress(String addr1, String addr2) { + String base = addr1 == null ? "" : addr1.trim(); + if (addr2 != null && !addr2.isBlank()) { + base = (base + " " + addr2.trim()).trim(); + } + return emptyToNull(base); + } + + // 주소 첫 두 토큰으로 "서울 성동구" 형태 지역명 추출 + private static String extractRegionName(String address) { + if (address == null || address.isBlank()) { + return null; + } + String[] tokens = address.trim().split("\\s+"); + if (tokens.length < 2) { + return null; + } + String sido = SIDO_SHORT_NAMES.getOrDefault(tokens[0], tokens[0]); + return sido + " " + tokens[1]; + } + + private static String firstNonBlank(String a, String b) { + if (a != null && !a.isBlank()) { + return a; + } + return emptyToNull(b); + } + + private static String emptyToNull(String value) { + return (value == null || value.isBlank()) ? null : value; + } + + private static String removeHtmlTags(String value) { + return value == null ? null : value.replaceAll(HTML_TAG_PATTERN, ""); + } + + private static Long parseLong(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + +} diff --git a/src/main/java/com/daytodo/domain/place/dto/request/PlaceReqDTO.java b/src/main/java/com/daytodo/domain/place/dto/request/PlaceReqDTO.java index 0c7fda4..898939a 100644 --- a/src/main/java/com/daytodo/domain/place/dto/request/PlaceReqDTO.java +++ b/src/main/java/com/daytodo/domain/place/dto/request/PlaceReqDTO.java @@ -2,6 +2,7 @@ import com.daytodo.domain.place.enums.BookmarkSortType; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Builder; public class PlaceReqDTO { @@ -12,6 +13,11 @@ public record GetBookmarkList( Long regionId ){} + // 장소 저장(북마크). contentId = 관광(KorService2) 콘텐츠 ID (내부 Place PK 아님) + public record CreateBookmark( + @NotNull(message = "contentId가 필요합니다.") Long contentId + ){} + // 장소 검색 조회 public record GetPlaceSearch( @NotBlank(message = "검색어를 입력해주세요.") String query diff --git a/src/main/java/com/daytodo/domain/place/dto/response/MagazineResDTO.java b/src/main/java/com/daytodo/domain/place/dto/response/MagazineResDTO.java new file mode 100644 index 0000000..49f4cbb --- /dev/null +++ b/src/main/java/com/daytodo/domain/place/dto/response/MagazineResDTO.java @@ -0,0 +1,50 @@ +package com.daytodo.domain.place.dto.response; + +import lombok.Builder; + +import java.util.List; + +public class MagazineResDTO { + + // 오늘의 Pick 매거진 목록 (HOM-004) + public record GetMagazineList( + List magazines + ) { + @Builder + public record MagazineItem( + Long magazineId, // 매거진 PK = 관광공사 contentId + String thumbnailUrl, + String placeName, + String regionName, + String tagline, // overview 로 대체 + boolean isAd + ) {} + } + + // 매거진 상세 + @Builder + public record GetMagazineDetail( + Long magazineId, // 매거진 PK = 관광공사 contentId (= placeId) + Long placeId, + String thumbnailUrl, + String category, + String placeName, + String address, + String businessHours, + String phone, + String content, // overview 로 대체 + List photos + ) {} + + // 매거진 사진 목록 (MAG-003) + public record GetMagazinePhotos( + List photos + ) {} + + @Builder + public record PhotoItem( + String imageId, // KorService2 serialnum (예: "2871004_3", 정수 아님) + String imageUrl, + Integer imageOrder // 응답 순서 기반 1부터 + ) {} +} diff --git a/src/main/java/com/daytodo/domain/place/dto/response/PlaceResDTO.java b/src/main/java/com/daytodo/domain/place/dto/response/PlaceResDTO.java index 119a191..a9800a7 100644 --- a/src/main/java/com/daytodo/domain/place/dto/response/PlaceResDTO.java +++ b/src/main/java/com/daytodo/domain/place/dto/response/PlaceResDTO.java @@ -21,6 +21,20 @@ public record BookmarkItem( ){} } + // 장소 저장(북마크) 결과. placeId 는 내부 Place PK (요청의 contentId 와 다름). + @Builder + public record CreateBookmark( + Long bookmarkId, + Long placeId + ){} + + // 장소 저장 해제 결과 + @Builder + public record DeleteBookmark( + Long bookmarkId, + boolean deleted + ){} + // 장소 검색 조회 public record GetPlaceSearch( List places diff --git a/src/main/java/com/daytodo/domain/place/entity/Magazine.java b/src/main/java/com/daytodo/domain/place/entity/Magazine.java new file mode 100644 index 0000000..10434fe --- /dev/null +++ b/src/main/java/com/daytodo/domain/place/entity/Magazine.java @@ -0,0 +1,48 @@ +package com.daytodo.domain.place.entity; + +import com.daytodo.domain.common.BaseEntity; +import com.daytodo.domain.place.enums.MagazineStatus; +import com.daytodo.domain.region.entity.Region; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 경량 매거진 엔티티. + * 콘텐츠 본문·사진은 KorService2(관광공사)에서 실시간으로 가져오고, + * 이 테이블은 서비스가 덧붙이는 값(광고 여부·노출 상태·대표 지역)만 관리한다. + * PK(magazine_id) 는 KorService2 의 관광 콘텐츠 ID(contentId) 를 그대로 사용한다. + * (매거진 1개 = 관광 콘텐츠 1개라 자연키로 두어 자동증가 PK 를 쓰지 않는다.) + * 사진이 하나도 없으면 클라이언트에서 디폴트 이미지로 대체한다(로컬 이미지 저장 안 함). + */ +@Getter +@Entity +@Table(name = "magazine") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Magazine extends BaseEntity { + + // 관광공사 contentId 를 매거진 PK 로 그대로 사용 (자동증가 아님, 수동 지정) + @Id + @Column(name = "magazine_id") + private Long magazineId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_id") + private Region region; + + @Column(name = "is_ad", nullable = false) + private boolean isAd; + + @Enumerated(EnumType.STRING) + @Column(name = "magazine_status", nullable = false, length = 20) + private MagazineStatus magazineStatus; +} diff --git a/src/main/java/com/daytodo/domain/place/entity/Place.java b/src/main/java/com/daytodo/domain/place/entity/Place.java index 41f2430..5035ba6 100644 --- a/src/main/java/com/daytodo/domain/place/entity/Place.java +++ b/src/main/java/com/daytodo/domain/place/entity/Place.java @@ -20,9 +20,14 @@ public class Place extends BaseEntity { @JoinColumn(name = "region_id") private Region region; - @Column (name = "naver_place_id", nullable = false, unique = true, length = 100) + // Naver 출처 장소 식별자. 관광(KorService2) 출처 장소는 값이 없으므로 nullable. + @Column (name = "naver_place_id", unique = true, length = 100) private String naverPlaceId; + // 한국관광공사 KorService2 콘텐츠 ID. 관광 출처 장소 식별자. + @Column (name = "tour_content_id", unique = true, length = 20) + private String tourContentId; + @Column (name = "place_name", nullable = false, length = 100) private String placeName; @@ -50,6 +55,34 @@ public class Place extends BaseEntity { @Column (name = "image_url", length = 500) private String imageUrl; + /** + * 관광(KorService2) 콘텐츠로부터 Place 를 생성한다 (북마크 시 lazy upsert 용). + * region 은 관광 지역코드 역매핑 결과이며, 미매핑 시 null 이다. + */ + public static Place ofTourContent( + String tourContentId, + Region region, + String placeName, + String category, + String address, + double latitude, + double longitude, + String phone, + String imageUrl + ) { + Place place = new Place(); + place.tourContentId = tourContentId; + place.region = region; + place.placeName = placeName; + place.category = category; + place.address = address; + place.latitude = latitude; + place.longitude = longitude; + place.phone = phone; + place.imageUrl = imageUrl; + return place; + } + public Place(Region region, String naverPlaceId, String placeName, String category, String address, String roadAddress, double latitude, double longitude, String phone, String description, String imageUrl) { diff --git a/src/main/java/com/daytodo/domain/place/enums/MagazineStatus.java b/src/main/java/com/daytodo/domain/place/enums/MagazineStatus.java new file mode 100644 index 0000000..7d37a4c --- /dev/null +++ b/src/main/java/com/daytodo/domain/place/enums/MagazineStatus.java @@ -0,0 +1,10 @@ +package com.daytodo.domain.place.enums; + +/** + * 매거진 노출 상태. + * EXPOSED: 목록/상세 노출, HIDDEN: 비노출. + */ +public enum MagazineStatus { + EXPOSED, + HIDDEN +} diff --git a/src/main/java/com/daytodo/domain/place/exception/code/PlaceErrorCode.java b/src/main/java/com/daytodo/domain/place/exception/code/PlaceErrorCode.java index 7e62ff7..a6b46dc 100644 --- a/src/main/java/com/daytodo/domain/place/exception/code/PlaceErrorCode.java +++ b/src/main/java/com/daytodo/domain/place/exception/code/PlaceErrorCode.java @@ -11,6 +11,18 @@ public enum PlaceErrorCode implements BaseErrorCode { // 장소 검색: Naver 지역 검색 API 호출 실패 NAVER_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요."), + + // 매거진: 한국관광공사 KorService2 호출 실패 + TOUR_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요."), + + // 매거진: 존재하지 않는 매거진(관광 콘텐츠) + MAGAZINE_NOT_FOUND(HttpStatus.NOT_FOUND, "MAGAZINE_NOT_FOUND", "매거진을 찾을 수 없습니다."), + + // 북마크: 이미 저장된 장소 + DUPLICATE_BOOKMARK(HttpStatus.CONFLICT, "DUPLICATE_BOOKMARK", "이미 저장된 장소입니다."), + + // 북마크: 저장된 장소 없음(해제 대상 없음) + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKMARK_NOT_FOUND", "저장된 장소를 찾을 수 없습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/daytodo/domain/place/infra/TourApiClient.java b/src/main/java/com/daytodo/domain/place/infra/TourApiClient.java new file mode 100644 index 0000000..5967839 --- /dev/null +++ b/src/main/java/com/daytodo/domain/place/infra/TourApiClient.java @@ -0,0 +1,135 @@ +package com.daytodo.domain.place.infra; + +import com.daytodo.domain.place.exception.code.PlaceErrorCode; +import com.daytodo.global.apiPayload.exception.ProjectException; +import com.daytodo.global.config.TourApiProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.function.Consumer; + +/** + * 한국관광공사 KorService2 호출 담당. + * 응답 가공 없이 원본 item 리스트만 반환한다 (가공은 MagazineConverter/Service 책임). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TourApiClient { + + private static final String AREA_BASED_LIST = "/areaBasedList2"; + private static final String DETAIL_COMMON = "/detailCommon2"; + private static final String DETAIL_INTRO = "/detailIntro2"; + private static final String DETAIL_IMAGE = "/detailImage2"; + + private static final String RESULT_CODE_SUCCESS = "0000"; + + private final RestClient tourRestClient; + private final TourApiProperties tourApiProperties; + + /** 지역기반 관광정보 목록. areaCode/sigunguCode 가 null 이면 전국. */ + public List areaBasedList(Integer areaCode, Integer sigunguCode, int numOfRows) { + URI uri = buildUri(AREA_BASED_LIST, b -> { + b.queryParam("numOfRows", numOfRows); + b.queryParam("pageNo", 1); + b.queryParam("arrange", "O"); // 대표이미지 있는 제목순 + if (areaCode != null) { + b.queryParam("areaCode", areaCode); + } + if (sigunguCode != null) { + b.queryParam("sigunguCode", sigunguCode); + } + }); + return call(uri, new ParameterizedTypeReference>() {}).items(); + } + + /** 공통정보(제목·주소·개요 등). 없으면 null. */ + public TourApiResponse.CommonItem detailCommon(String contentId) { + URI uri = buildUri(DETAIL_COMMON, b -> b.queryParam("contentId", encode(contentId))); + List items = + call(uri, new ParameterizedTypeReference>() {}).items(); + return items.isEmpty() ? null : items.get(0); + } + + /** 소개정보(영업시간 등). contentTypeId 필수. 없으면 null. */ + public TourApiResponse.IntroItem detailIntro(String contentId, String contentTypeId) { + URI uri = buildUri(DETAIL_INTRO, b -> { + b.queryParam("contentId", encode(contentId)); + b.queryParam("contentTypeId", encode(contentTypeId)); + }); + List items = + call(uri, new ParameterizedTypeReference>() {}).items(); + return items.isEmpty() ? null : items.get(0); + } + + /** 이미지정보(콘텐츠 사진). image_order 정렬은 호출부 책임. */ + public List detailImage(String contentId) { + URI uri = buildUri(DETAIL_IMAGE, b -> { + b.queryParam("contentId", encode(contentId)); + b.queryParam("imageYN", "Y"); + b.queryParam("numOfRows", 30); + b.queryParam("pageNo", 1); + }); + return call(uri, new ParameterizedTypeReference>() {}).items(); + } + + private TourApiResponse call(URI uri, ParameterizedTypeReference> type) { + TourApiResponse response; + try { + response = tourRestClient.get() + .uri(uri) + .retrieve() + .body(type); + } catch (RestClientException e) { + log.error("KorService2 호출 실패. uri={}", uri.getPath(), e); + throw new ProjectException(PlaceErrorCode.TOUR_API_ERROR); + } + + // body 가 없거나(200 이어도 본문 없음) header 가 없으면 오류로 처리한다. + if (response == null || response.response() == null || response.response().header() == null) { + log.error("KorService2 응답 본문이 비어있습니다. uri={}", uri.getPath()); + throw new ProjectException(PlaceErrorCode.TOUR_API_ERROR); + } + + // KorService2 는 HTTP 200 이어도 resultCode 가 0000 이 아니면 실패다. + TourApiResponse.Header header = response.response().header(); + if (!RESULT_CODE_SUCCESS.equals(header.resultCode())) { + log.error("KorService2 오류 응답. uri={}, resultCode={}, resultMsg={}", + uri.getPath(), header.resultCode(), header.resultMsg()); + throw new ProjectException(PlaceErrorCode.TOUR_API_ERROR); + } + + return response; + } + + /** + * 공통 파라미터(serviceKey, MobileOS, MobileApp, _type)를 붙인 절대 URI 생성. + * serviceKey 는 이미 인코딩된 키이므로 build(true) 로 이중 인코딩을 막는다. + * build(true) 는 모든 값이 인코딩되어 있다고 가정하므로, + * contentId 등 외부 입력값은 호출부에서 {@link #encode(String)} 로 미리 인코딩해 넘긴다. + */ + private URI buildUri(String path, Consumer params) { + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(tourApiProperties.baseUrl() + path) + .queryParam("serviceKey", tourApiProperties.serviceKey()) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "daytodo") + .queryParam("_type", "json"); + params.accept(builder); + return builder.build(true).toUri(); + } + + // build(true) 로 조립하기 위해 외부 입력 쿼리 값을 미리 인코딩한다. + private static String encode(String value) { + return value == null ? null : UriUtils.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/daytodo/domain/place/infra/TourApiResponse.java b/src/main/java/com/daytodo/domain/place/infra/TourApiResponse.java new file mode 100644 index 0000000..2e6b8b0 --- /dev/null +++ b/src/main/java/com/daytodo/domain/place/infra/TourApiResponse.java @@ -0,0 +1,130 @@ +package com.daytodo.domain.place.infra; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * 한국관광공사 KorService2 공통 응답 구조. + *
+ * { "response": { "header": {...}, "body": { "items": { "item": [ ... ] }, ... } } }
+ * 
+ * 결과 0건일 때 items 가 "" (빈 문자열)로 오므로 + * application.yml 의 accept-empty-string-as-null-object=true 로 items=null 처리한다. + * 필드명은 KorService2 JSON 키(소문자)와 일치해야 한다. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record TourApiResponse( + Response response +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Response( + Header header, + Body body + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Header( + String resultCode, + String resultMsg + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Body( + Items items, + Integer numOfRows, + Integer pageNo, + Integer totalCount + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Items( + List item + ) {} + + /** items.item 리스트를 항상 non-null 로 꺼낸다. */ + public List items() { + if (response == null || response.body() == null || response.body().items() == null) { + return List.of(); + } + List item = response.body().items().item(); + return item == null ? List.of() : item; + } + + // ---- 엔드포인트별 item 레코드 ---- + + /** areaBasedList2 (지역기반 관광정보) 목록 아이템 */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record AreaItem( + String contentid, + String contenttypeid, + String title, + String addr1, + String addr2, + String firstimage, + String firstimage2, + String areacode, + String sigungucode, + String tel, + String cat1, + String cat2, + String cat3, + String mapx, + String mapy + ) {} + + /** detailCommon2 (공통정보) 아이템 */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record CommonItem( + String contentid, + String contenttypeid, + String title, + String tel, + String homepage, + String firstimage, + String firstimage2, + String areacode, + String sigungucode, + String addr1, + String addr2, + String zipcode, + String mapx, + String mapy, + String overview, + String cat1, + String cat2, + String cat3 + ) {} + + /** detailIntro2 (소개정보) 아이템 — contentTypeId 별 영업시간 필드가 다르다. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record IntroItem( + String contentid, + String contenttypeid, + // 음식점(39) + String opentimefood, + String restdatefood, + // 관광지(12) + String usetime, + String restdate, + // 문화시설(14) + String usetimeculture, + String restdateculture, + // 레포츠(28) + String usetimeleports, + String restdateleports, + // 쇼핑(38) + String opentime, + String restdateshopping + ) {} + + /** detailImage2 (이미지정보) 아이템 */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ImageItem( + String contentid, + String originimgurl, + String smallimageurl, + String imgname, + String serialnum + ) {} +} diff --git a/src/main/java/com/daytodo/domain/place/repository/BookmarkPlaceRepository.java b/src/main/java/com/daytodo/domain/place/repository/BookmarkPlaceRepository.java index ea4d48d..e7865fd 100644 --- a/src/main/java/com/daytodo/domain/place/repository/BookmarkPlaceRepository.java +++ b/src/main/java/com/daytodo/domain/place/repository/BookmarkPlaceRepository.java @@ -6,9 +6,16 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface BookmarkPlaceRepository extends JpaRepository { + // 저장 중복 검사 (uk_bookmark_place_user_place 와 동일 기준) + boolean existsByUser_IdAndPlace_PlaceId(Long userId, Long placeId); + + // 해제 시 소유권 확인 + Optional findByIdAndUser_Id(Long id, Long userId); + /* * 저장 목록 공통 조회 조건 * - 응답의 regionName 조립에 Region, Region.parent 가 필요하므로 함께 fetch diff --git a/src/main/java/com/daytodo/domain/place/repository/MagazineRepository.java b/src/main/java/com/daytodo/domain/place/repository/MagazineRepository.java new file mode 100644 index 0000000..2e1d5f7 --- /dev/null +++ b/src/main/java/com/daytodo/domain/place/repository/MagazineRepository.java @@ -0,0 +1,20 @@ +package com.daytodo.domain.place.repository; + +import com.daytodo.domain.place.entity.Magazine; +import com.daytodo.domain.place.enums.MagazineStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +public interface MagazineRepository extends JpaRepository { + + /** + * 목록 광고 오버레이용: 주어진 매거진 ID(=관광 contentId) 중 노출 상태인 것만 조회. + * 결과의 magazineId 는 목록에서 isAd 표시·상단 정렬에 사용. + */ + List findByMagazineIdInAndMagazineStatus( + Collection magazineIds, + MagazineStatus magazineStatus + ); +} diff --git a/src/main/java/com/daytodo/domain/place/repository/PlaceRepository.java b/src/main/java/com/daytodo/domain/place/repository/PlaceRepository.java index 2951298..8b0d7e6 100644 --- a/src/main/java/com/daytodo/domain/place/repository/PlaceRepository.java +++ b/src/main/java/com/daytodo/domain/place/repository/PlaceRepository.java @@ -9,5 +9,8 @@ * 이미 존재한다면 이 파일은 만들지 않아도 됩니다 (findAllById는 JpaRepository 기본 제공 메서드). */ public interface PlaceRepository extends JpaRepository { + + // 북마크 lazy upsert 용: 관광 콘텐츠 ID 로 기존 Place 조회 + Optional findByTourContentId(String tourContentId); Optional findByNaverPlaceId(String naverPlaceId); } diff --git a/src/main/java/com/daytodo/domain/place/service/MagazineService.java b/src/main/java/com/daytodo/domain/place/service/MagazineService.java new file mode 100644 index 0000000..e261e89 --- /dev/null +++ b/src/main/java/com/daytodo/domain/place/service/MagazineService.java @@ -0,0 +1,140 @@ +package com.daytodo.domain.place.service; + +import com.daytodo.domain.place.converter.MagazineConverter; +import com.daytodo.domain.place.dto.response.MagazineResDTO; +import com.daytodo.domain.place.entity.Magazine; +import com.daytodo.domain.place.enums.MagazineStatus; +import com.daytodo.domain.place.exception.code.PlaceErrorCode; +import com.daytodo.domain.place.infra.TourApiClient; +import com.daytodo.domain.place.infra.TourApiResponse; +import com.daytodo.domain.place.repository.MagazineRepository; +import com.daytodo.domain.region.entity.Region; +import com.daytodo.domain.region.repository.RegionRepository; +import com.daytodo.domain.user.entity.mapping.UserInterestRegion; +import com.daytodo.domain.user.repository.UserInterestRegionRepository; +import com.daytodo.global.apiPayload.exception.ProjectException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 매거진 조회 서비스. + * 목록은 관심지역 기준 areaBasedList2 로 조회하고, Magazine 테이블로 광고 여부를 오버레이한다. + * 상세/사진은 KorService2 를 실시간 프록시한다. + */ +@Service +@RequiredArgsConstructor +public class MagazineService { + + private static final int LIST_SIZE = 10; // tagline(overview) N+1 비용 때문에 작게 유지 + + private final TourApiClient tourApiClient; + private final MagazineRepository magazineRepository; + private final RegionRepository regionRepository; + private final UserInterestRegionRepository userInterestRegionRepository; + + public MagazineResDTO.GetMagazineList getMagazineList(Long userId) { + Region region = resolveInterestRegion(userId); + Integer areaCode = region == null ? null : region.getAreaCode(); + Integer sigunguCode = region == null ? null : region.getSigunguCode(); + + List items = tourApiClient.areaBasedList(areaCode, sigunguCode, LIST_SIZE); + if (items.isEmpty()) { + return new MagazineResDTO.GetMagazineList(List.of()); + } + + Map adFlags = loadAdFlags(items); + + List magazines = items.stream() + .map(item -> { + boolean isAd = adFlags.getOrDefault(item.contentid(), false); + String tagline = fetchOverview(item.contentid()); + return MagazineConverter.toMagazineItem(item, isAd, tagline); + }) + // 광고 우선 정렬 (그 외 순서는 areaBasedList2 정렬 유지) + .sorted((a, b) -> Boolean.compare(b.isAd(), a.isAd())) + .toList(); + + return new MagazineResDTO.GetMagazineList(magazines); + } + + public MagazineResDTO.GetMagazineDetail getMagazineDetail(Long magazineId) { + String contentId = String.valueOf(magazineId); + + TourApiResponse.CommonItem common = tourApiClient.detailCommon(contentId); + if (common == null) { + throw new ProjectException(PlaceErrorCode.MAGAZINE_NOT_FOUND); + } + + TourApiResponse.IntroItem intro = tourApiClient.detailIntro(contentId, common.contenttypeid()); + List images = tourApiClient.detailImage(contentId); + + return MagazineConverter.toDetail(common, intro, images); + } + + public MagazineResDTO.GetMagazinePhotos getMagazinePhotos(Long magazineId) { + String contentId = String.valueOf(magazineId); + + List images = tourApiClient.detailImage(contentId); + if (images.isEmpty() && tourApiClient.detailCommon(contentId) == null) { + throw new ProjectException(PlaceErrorCode.MAGAZINE_NOT_FOUND); + } + return MagazineConverter.toPhotos(images); + } + + // 관심지역 중 관광 지역코드가 매핑된 첫 지역을 사용. 없으면 전국(null). + private Region resolveInterestRegion(Long userId) { + List regionIds = userInterestRegionRepository.findAllByUserIdOrderByIdAsc(userId).stream() + .map(UserInterestRegion::getRegionId) + .toList(); + if (regionIds.isEmpty()) { + return null; + } + return regionRepository.findAllByRegionIdIn(regionIds).stream() + .filter(r -> r.getAreaCode() != null) + .findFirst() + .orElse(null); + } + + // 노출 상태인 매거진만 조회해 magazineId(=contentId, String) -> isAd 로 매핑. + // DB 조회는 Long(BIGINT)으로 하되, 목록 매칭 편의를 위해 map 키는 String 으로 유지한다. + private Map loadAdFlags(List items) { + List magazineIds = items.stream() + .map(TourApiResponse.AreaItem::contentid) + .map(MagazineService::parseContentId) + .filter(id -> id != null) + .toList(); + if (magazineIds.isEmpty()) { + return Map.of(); + } + return magazineRepository.findByMagazineIdInAndMagazineStatus(magazineIds, MagazineStatus.EXPOSED).stream() + .collect(Collectors.toMap(m -> String.valueOf(m.getMagazineId()), Magazine::isAd, (a, b) -> a || b)); + } + + // KorService2 contentId(숫자 문자열) -> Long. 비정상 값은 null 로 흡수. + private static Long parseContentId(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + // 목록 tagline 용 overview. 실패해도 목록 전체가 죽지 않도록 null 로 흡수. + private String fetchOverview(String contentId) { + try { + return Optional.ofNullable(tourApiClient.detailCommon(contentId)) + .map(TourApiResponse.CommonItem::overview) + .orElse(null); + } catch (ProjectException e) { + return null; + } + } +} diff --git a/src/main/java/com/daytodo/domain/place/service/PlaceBookmarkService.java b/src/main/java/com/daytodo/domain/place/service/PlaceBookmarkService.java index 6b99b9b..50bf77c 100644 --- a/src/main/java/com/daytodo/domain/place/service/PlaceBookmarkService.java +++ b/src/main/java/com/daytodo/domain/place/service/PlaceBookmarkService.java @@ -1,12 +1,24 @@ package com.daytodo.domain.place.service; import com.daytodo.domain.place.converter.BookmarkPlaceConverter; +import com.daytodo.domain.place.converter.MagazineConverter; import com.daytodo.domain.place.dto.request.PlaceReqDTO; import com.daytodo.domain.place.dto.response.PlaceResDTO; +import com.daytodo.domain.place.entity.Place; import com.daytodo.domain.place.entity.mapping.BookmarkPlace; import com.daytodo.domain.place.enums.BookmarkSortType; +import com.daytodo.domain.place.exception.code.PlaceErrorCode; +import com.daytodo.domain.place.infra.TourApiClient; +import com.daytodo.domain.place.infra.TourApiResponse; import com.daytodo.domain.place.repository.BookmarkPlaceRepository; +import com.daytodo.domain.place.repository.PlaceRepository; +import com.daytodo.domain.region.entity.Region; +import com.daytodo.domain.region.repository.RegionRepository; +import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.repository.UserRepository; +import com.daytodo.global.apiPayload.exception.ProjectException; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +29,10 @@ public class PlaceBookmarkService { private final BookmarkPlaceRepository bookmarkPlaceRepository; + private final PlaceRepository placeRepository; + private final RegionRepository regionRepository; + private final UserRepository userRepository; + private final TourApiClient tourApiClient; @Transactional(readOnly = true) public PlaceResDTO.GetBookmarkList getBookmarkList( @@ -35,4 +51,143 @@ public PlaceResDTO.GetBookmarkList getBookmarkList( return BookmarkPlaceConverter.toBookmarkList(bookmarkPlaces); } + + /** + * 장소 저장(북마크). contentId 는 관광(KorService2) 콘텐츠 ID 이며, 내부 Place PK 가 아니다. + * 자체 Place 가 없으면 detailCommon2 로 조회해 Place 를 만들어 넣는다(lazy upsert). + */ + @Transactional + public PlaceResDTO.CreateBookmark createBookmark(Long userId, Long contentId) { + String tourContentId = String.valueOf(contentId); + + Place place = findOrCreatePlace(tourContentId); + + if (bookmarkPlaceRepository.existsByUser_IdAndPlace_PlaceId(userId, place.getPlaceId())) { + throw new ProjectException(PlaceErrorCode.DUPLICATE_BOOKMARK); + } + + User user = userRepository.getReferenceById(userId); + BookmarkPlace saved; + try { + saved = bookmarkPlaceRepository.saveAndFlush(new BookmarkPlace(user, place)); + } catch (DataIntegrityViolationException e) { + // 동시에 동일 요청이 들어와 유니크 제약(uk_bookmark_place_user_place)에 걸린 경우 + throw new ProjectException(PlaceErrorCode.DUPLICATE_BOOKMARK); + } + + return PlaceResDTO.CreateBookmark.builder() + .bookmarkId(saved.getId()) + .placeId(place.getPlaceId()) + .build(); + } + + /** + * 관광 콘텐츠 ID 로 Place 를 조회하거나 없으면 생성한다. + * 동시에 동일 contentId 로 생성 요청이 들어오면 tour_content_id 유니크 제약에 걸릴 수 있으므로, + * DataIntegrityViolationException 발생 시 상대 트랜잭션이 저장한 Place 를 재조회한다. + */ + private Place findOrCreatePlace(String tourContentId) { + return placeRepository.findByTourContentId(tourContentId) + .orElseGet(() -> { + try { + return placeRepository.saveAndFlush(createPlaceFromTour(tourContentId)); + } catch (DataIntegrityViolationException e) { + return placeRepository.findByTourContentId(tourContentId) + .orElseThrow(() -> new ProjectException(PlaceErrorCode.TOUR_API_ERROR)); + } + }); + } + + /** + * 장소 저장 해제. 본인 소유 북마크만 하드 삭제한다. + */ + @Transactional + public PlaceResDTO.DeleteBookmark deleteBookmark(Long userId, Long bookmarkId) { + BookmarkPlace bookmark = bookmarkPlaceRepository.findByIdAndUser_Id(bookmarkId, userId) + .orElseThrow(() -> new ProjectException(PlaceErrorCode.BOOKMARK_NOT_FOUND)); + + bookmarkPlaceRepository.delete(bookmark); + + return PlaceResDTO.DeleteBookmark.builder() + .bookmarkId(bookmarkId) + .deleted(true) + .build(); + } + + // 관광 콘텐츠로부터 Place 생성. 존재하지 않는 contentId 면 MAGAZINE_NOT_FOUND. + private Place createPlaceFromTour(String contentId) { + TourApiResponse.CommonItem common = tourApiClient.detailCommon(contentId); + if (common == null) { + throw new ProjectException(PlaceErrorCode.MAGAZINE_NOT_FOUND); + } + + Region region = resolveRegion(common.areacode(), common.sigungucode()); + String category = MagazineConverter.categoryName(common.contenttypeid()); + + return Place.ofTourContent( + contentId, + region, + nullToEmpty(common.title()), + category == null ? "기타" : category, + joinAddress(common.addr1(), common.addr2()), + parseCoordinate(common.mapy()), // 위도 + parseCoordinate(common.mapx()), // 경도 + emptyToNull(common.tel()), + firstImage(common) + ); + } + + private Region resolveRegion(String areaCode, String sigunguCode) { + Integer area = parseInt(areaCode); + Integer sigungu = parseInt(sigunguCode); + if (area == null || sigungu == null) { + return null; + } + return regionRepository.findFirstByAreaCodeAndSigunguCode(area, sigungu).orElse(null); + } + + private static String firstImage(TourApiResponse.CommonItem common) { + if (common.firstimage() != null && !common.firstimage().isBlank()) { + return common.firstimage(); + } + return emptyToNull(common.firstimage2()); + } + + private static String joinAddress(String addr1, String addr2) { + String base = addr1 == null ? "" : addr1.trim(); + if (addr2 != null && !addr2.isBlank()) { + base = (base + " " + addr2.trim()).trim(); + } + return base; + } + + private static double parseCoordinate(String value) { + if (value == null || value.isBlank()) { + return 0.0; + } + try { + return Double.parseDouble(value.trim()); + } catch (NumberFormatException e) { + return 0.0; + } + } + + private static Integer parseInt(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private static String emptyToNull(String value) { + return (value == null || value.isBlank()) ? null : value; + } } diff --git a/src/main/java/com/daytodo/domain/region/entity/Region.java b/src/main/java/com/daytodo/domain/region/entity/Region.java index 7d45ee8..a26f219 100644 --- a/src/main/java/com/daytodo/domain/region/entity/Region.java +++ b/src/main/java/com/daytodo/domain/region/entity/Region.java @@ -26,6 +26,13 @@ public class Region { @Column (name = "region_level", nullable = false, length = 20) private RegionLevel regionLevel; + // 한국관광공사 KorService2 지역코드 매핑 (미매핑 시 null → 전국 fallback) + @Column (name = "area_code") + private Integer areaCode; + + @Column (name = "sigungu_code") + private Integer sigunguCode; + public Region(Region parent, String regionName, RegionLevel regionLevel) { this.parent = parent; this.regionName = regionName; diff --git a/src/main/java/com/daytodo/domain/region/repository/RegionRepository.java b/src/main/java/com/daytodo/domain/region/repository/RegionRepository.java index 1241aa1..9339850 100644 --- a/src/main/java/com/daytodo/domain/region/repository/RegionRepository.java +++ b/src/main/java/com/daytodo/domain/region/repository/RegionRepository.java @@ -6,8 +6,12 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; public interface RegionRepository extends JpaRepository { @EntityGraph(attributePaths = "parent") List findAllByRegionIdIn(Collection regionIds); + + // 관광 지역코드 -> Region 역매핑 (북마크 시 Place.region 세팅용). 미매핑 시 empty. + Optional findFirstByAreaCodeAndSigunguCode(Integer areaCode, Integer sigunguCode); } diff --git a/src/main/java/com/daytodo/domain/user/repository/WithdrawnUserCleanupRepository.java b/src/main/java/com/daytodo/domain/user/repository/WithdrawnUserCleanupRepository.java index 53e49dd..2d0863c 100644 --- a/src/main/java/com/daytodo/domain/user/repository/WithdrawnUserCleanupRepository.java +++ b/src/main/java/com/daytodo/domain/user/repository/WithdrawnUserCleanupRepository.java @@ -14,11 +14,7 @@ public void removeUserReferences(Long userId) { delete("delete from RecommendationComment entity where entity.userId = :userId", userId); update("update PlaceRecommendation entity set entity.recommenderId = null where entity.recommenderId = :userId", userId); - delete(""" - delete from BookmarkPlace entity - where entity.user.id = :userId or entity.bookmarkCategory.user.id = :userId - """, userId); - delete("delete from BookmarkCategory entity where entity.user.id = :userId", userId); + delete("delete from BookmarkPlace entity where entity.user.id = :userId", userId); delete("delete from SocialAccount entity where entity.user.id = :userId", userId); delete("delete from Notification entity where entity.user.id = :userId", userId); delete("delete from UserNotificationSetting entity where entity.user.id = :userId", userId); diff --git a/src/main/java/com/daytodo/global/config/RestClientConfig.java b/src/main/java/com/daytodo/global/config/RestClientConfig.java index 2c45c50..ace5005 100644 --- a/src/main/java/com/daytodo/global/config/RestClientConfig.java +++ b/src/main/java/com/daytodo/global/config/RestClientConfig.java @@ -3,18 +3,43 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import java.time.Duration; + @Configuration -@EnableConfigurationProperties(NaverApiProperties.class) +@EnableConfigurationProperties({NaverApiProperties.class, TourApiProperties.class}) public class RestClientConfig { + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(5); + @Bean public RestClient naverRestClient(NaverApiProperties properties) { return RestClient.builder() + .requestFactory(timeoutRequestFactory()) .baseUrl(properties.baseUrl()) .defaultHeader("X-Naver-Client-Id", properties.clientId()) .defaultHeader("X-Naver-Client-Secret", properties.clientSecret()) .build(); } + + // 한국관광공사 KorService2. serviceKey 는 각 호출에서 쿼리파라미터로 붙인다. + @Bean + public RestClient tourRestClient(TourApiProperties properties) { + return RestClient.builder() + .requestFactory(timeoutRequestFactory()) + .baseUrl(properties.baseUrl()) + .build(); + } + + // 외부 API 지연 시 요청 스레드가 장시간 점유되지 않도록 connect/read timeout 을 명시한다. + private static ClientHttpRequestFactory timeoutRequestFactory() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT); + factory.setReadTimeout(READ_TIMEOUT); + return factory; + } } \ No newline at end of file diff --git a/src/main/java/com/daytodo/global/config/SecurityConfig.java b/src/main/java/com/daytodo/global/config/SecurityConfig.java index 496f22d..6b0260c 100644 --- a/src/main/java/com/daytodo/global/config/SecurityConfig.java +++ b/src/main/java/com/daytodo/global/config/SecurityConfig.java @@ -55,9 +55,6 @@ public class SecurityConfig { // JWT 전환 대상 외의 기존 Course API 접근 정책은 변경하지 않는다. private static final String[] LEGACY_PERMIT_ALL_COURSE_PATHS = { - "/courses/today", - "/courses/*/complete", - "/courses/*/photos", "/courses/*/setting", "/courses/*/places", "/courses/*/members", @@ -84,6 +81,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.GET, "/courses/diaries/{diaryId}/course").authenticated() .requestMatchers(HttpMethod.GET, "/courses/diaries").authenticated() .requestMatchers(HttpMethod.GET, "/courses/*/memory-photos").authenticated() + // TodayCourse는 JWT 인증 전환 완료 - LEGACY permitAll 보다 먼저 매칭되어야 함 + .requestMatchers(HttpMethod.GET, "/courses/today").authenticated() + .requestMatchers(HttpMethod.POST, "/courses/*/complete").authenticated() + .requestMatchers(HttpMethod.POST, "/courses/*/photos").authenticated() .requestMatchers(HttpMethod.GET, "/users/policies").permitAll() .requestMatchers(HttpMethod.GET, "/users/profile", diff --git a/src/main/java/com/daytodo/global/config/TourApiProperties.java b/src/main/java/com/daytodo/global/config/TourApiProperties.java new file mode 100644 index 0000000..97584f7 --- /dev/null +++ b/src/main/java/com/daytodo/global/config/TourApiProperties.java @@ -0,0 +1,14 @@ +package com.daytodo.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 한국관광공사 국문 관광정보 서비스(KorService2) 연동 설정. + * serviceKey 는 공공데이터포털에서 발급받은 인코딩키를 그대로 사용한다. + */ +@ConfigurationProperties(prefix = "tour") +public record TourApiProperties( + String baseUrl, + String serviceKey +) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c49a0be..4dc8761 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,11 @@ spring: application: name: "daytodo" + jackson: + deserialization: + # KorService2 는 결과 0건일 때 items 를 "" 로 내려주므로 빈 문자열을 null 객체로 처리 + accept-empty-string-as-null-object: true + config: import: optional:file:.env[.properties] @@ -54,6 +59,10 @@ naver: client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} +tour: + base-url: https://apis.data.go.kr/B551011/KorService2 + service-key: ${TOUR_SERVICE_KEY} + aws: s3: bucket: ${AWS_S3_BUCKET:} diff --git a/src/test/java/com/daytodo/domain/course/controller/TodayCourseControllerTest.java b/src/test/java/com/daytodo/domain/course/controller/TodayCourseControllerTest.java index 3eaab43..cd76789 100644 --- a/src/test/java/com/daytodo/domain/course/controller/TodayCourseControllerTest.java +++ b/src/test/java/com/daytodo/domain/course/controller/TodayCourseControllerTest.java @@ -1,6 +1,6 @@ package com.daytodo.domain.course.controller; -import com.daytodo.domain.course.dto.response.CourseResDTO; +import com.daytodo.domain.course.dto.response.TodayCourseResponse; import com.daytodo.domain.course.exception.code.CourseErrorCode; import com.daytodo.domain.course.service.TodayCourseService; import com.daytodo.global.apiPayload.exception.ProjectException; @@ -37,7 +37,7 @@ class TodayCourseControllerTest { @DisplayName("오늘 진행 중인 코스가 없으면 200과 함께 todayCourse가 null로 내려간다") void todayCourseNull() throws Exception { given(todayCourseService.getTodayCourse(anyLong())) - .willReturn(new CourseResDTO.GetTodayCourse(null)); + .willReturn(new TodayCourseResponse.GetTodayCourse(null)); mockMvc.perform(get("/courses/today").header("X-User-Id", 1L)) .andExpect(status().isOk()) @@ -47,7 +47,9 @@ void todayCourseNull() throws Exception { @Test @DisplayName("저장할 이미지가 없으면 400 INVALID_PARAMETER로 응답한다") void emptyImageUrls() throws Exception { - given(todayCourseService.saveMemoryPhotos(anyLong(), anyLong(), any())) + // WebMvc 슬라이스에선 인증 principal이 태워지지 않아 userId 인자는 null 이므로 any() 로 매칭한다. + // (이 테스트의 검증 대상은 서비스 예외 -> 400 INVALID_PARAMETER 매핑이다.) + given(todayCourseService.saveMemoryPhotos(any(), anyLong(), any())) .willThrow(new ProjectException(CourseErrorCode.EMPTY_MEMORY_PHOTO)); mockMvc.perform(post("/courses/1/photos") diff --git a/src/test/java/com/daytodo/domain/course/service/CourseServiceTest.java b/src/test/java/com/daytodo/domain/course/service/CourseServiceTest.java index 957deee..c5144e0 100644 --- a/src/test/java/com/daytodo/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/daytodo/domain/course/service/CourseServiceTest.java @@ -15,6 +15,10 @@ import com.daytodo.domain.course.repository.CoursePlaceRepository; import com.daytodo.domain.course.repository.CourseRepository; import com.daytodo.domain.course.repository.InviteCodeRepository; +import com.daytodo.domain.place.repository.PlaceRecommendationRepository; +import com.daytodo.domain.place.repository.PlaceRepository; +import com.daytodo.domain.place.repository.RecommendationCommentRepository; +import com.daytodo.domain.place.repository.RecommendationLikeRepository; import com.daytodo.domain.region.entity.Region; import com.daytodo.domain.region.enums.RegionLevel; import com.daytodo.domain.region.repository.RegionRepository;