From 8755f2ac49b52ca84834c9adab2868e5afaf156a Mon Sep 17 00:00:00 2001 From: SUH SAECHAN Date: Mon, 23 Feb 2026 14:29:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=ED=8F=B4=EB=8D=94=20CRUD=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94-=EC=9E=A5=EC=86=8C=20=EA=B4=80=EB=A6=AC=20AP?= =?UTF-8?q?I=20=EA=B5=AC=ED=98=84=20=ED=95=84=EC=9A=94=20:=20fix=20:=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20Major=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=EC=88=98=EC=A0=95=20https://github.com/MapSee-Lab/?= =?UTF-8?q?MapSy-BE/issues/26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - addPlaceToFolder에 @Valid 어노테이션 추가 - Folder 엔티티 @Setter 제거 → updateName(), updateVisibility() 도메인 메서드로 변경 - UniqueConstraint + Soft Delete 충돌 해결: 재추가 시 기존 Soft Delete 레코드 restore() 복원 Co-Authored-By: Claude Opus 4.6 --- .../suhsaechan/mapsy/place/entity/Folder.java | 10 ++++- .../repository/FolderPlaceRepository.java | 2 + .../mapsy/place/service/FolderService.java | 38 +++++++++++++++---- .../web/controller/FolderController.java | 2 +- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Folder.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Folder.java index e33bacf..87b1df8 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Folder.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Folder.java @@ -19,12 +19,10 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; @Entity @Builder @Getter -@Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class Folder extends SoftDeletableBaseEntity { @@ -56,6 +54,14 @@ public class Folder extends SoftDeletableBaseEntity { @Builder.Default private Boolean isDefault = false; + public void updateName(String name) { + this.name = name; + } + + public void updateVisibility(FolderVisibility visibility) { + this.visibility = visibility; + } + @PrePersist protected void onCreate() { if (name == null) { diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderPlaceRepository.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderPlaceRepository.java index 2c599e4..6a39f67 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderPlaceRepository.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/FolderPlaceRepository.java @@ -23,6 +23,8 @@ public interface FolderPlaceRepository extends JpaRepository Optional findByFolderAndPlaceAndDeletedAtIsNull(Folder folder, Place place); + Optional findByFolderAndPlace(Folder folder, Place place); + boolean existsByFolderAndPlaceAndDeletedAtIsNull(Folder folder, Place place); List findByFolderAndDeletedAtIsNull(Folder folder); diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/FolderService.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/FolderService.java index 1cca629..afe409e 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/FolderService.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/FolderService.java @@ -22,6 +22,7 @@ import kr.suhsaechan.mapsy.place.repository.FolderRepository; import kr.suhsaechan.mapsy.place.repository.PlaceRepository; import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -84,10 +85,10 @@ public UpdateFolderResponse updateFolder(Member member, UUID folderId, UpdateFol Folder folder = getFolderWithOwnerValidation(folderId, member); if (request.getName() != null) { - folder.setName(request.getName()); + folder.updateName(request.getName()); } if (request.getVisibility() != null) { - folder.setVisibility(request.getVisibility()); + folder.updateVisibility(request.getVisibility()); } Folder savedFolder = folderRepository.save(folder); @@ -151,9 +152,20 @@ public AddFolderPlaceResponse addPlaceToFolder(Member member, UUID folderId, Add Place place = placeRepository.findById(request.getPlaceId()) .orElseThrow(() -> new CustomException(ErrorCode.PLACE_NOT_FOUND)); - // 중복 체크 - if (folderPlaceRepository.existsByFolderAndPlaceAndDeletedAtIsNull(folder, place)) { - throw new CustomException(ErrorCode.FOLDER_PLACE_ALREADY_EXISTS); + // 기존 레코드 조회 (Soft Delete 포함) + Optional existingFolderPlace = folderPlaceRepository.findByFolderAndPlace(folder, place); + + if (existingFolderPlace.isPresent()) { + FolderPlace fp = existingFolderPlace.get(); + if (fp.isActive()) { + // 이미 활성 상태면 중복 에러 + throw new CustomException(ErrorCode.FOLDER_PLACE_ALREADY_EXISTS); + } + // Soft Delete된 레코드 복원 + fp.restore(); + FolderPlace restoredFolderPlace = folderPlaceRepository.save(fp); + log.info("Place restored to folder: folderPlaceId={}", restoredFolderPlace.getId()); + return AddFolderPlaceResponse.from(restoredFolderPlace); } int maxPosition = folderPlaceRepository.findMaxPositionByFolder(folder); @@ -211,9 +223,19 @@ public void addPlaceToDefaultFolder(Member member, Place place) { Folder defaultFolder = folderRepository.findByOwnerAndIsDefaultTrueAndDeletedAtIsNull(member) .orElseGet(() -> createDefaultFolder(member)); - // 이미 기본 폴더에 있으면 스킵 - if (folderPlaceRepository.existsByFolderAndPlaceAndDeletedAtIsNull(defaultFolder, place)) { - log.debug("Place already in default folder: placeId={}", place.getId()); + // 기존 레코드 조회 (Soft Delete 포함) + Optional existingFolderPlace = folderPlaceRepository.findByFolderAndPlace(defaultFolder, place); + + if (existingFolderPlace.isPresent()) { + FolderPlace fp = existingFolderPlace.get(); + if (fp.isActive()) { + log.debug("Place already in default folder: placeId={}", place.getId()); + return; + } + // Soft Delete된 레코드 복원 + fp.restore(); + folderPlaceRepository.save(fp); + log.info("Place restored to default folder: placeId={}", place.getId()); return; } diff --git a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderController.java b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderController.java index 0cfea9b..2a0e6c9 100644 --- a/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderController.java +++ b/MS-Web/src/main/java/kr/suhsaechan/mapsy/web/controller/FolderController.java @@ -96,7 +96,7 @@ public ResponseEntity getFolderPlaces( public ResponseEntity addPlaceToFolder( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable UUID folderId, - @RequestBody AddFolderPlaceRequest request + @Valid @RequestBody AddFolderPlaceRequest request ) { log.info("Add place to folder request from member: {}, folderId: {}, placeId: {}", userDetails.getMemberId(), folderId, request.getPlaceId()); From ca7503d1333dd9d4dd284016031d0a199ed21763 Mon Sep 17 00:00:00 2001 From: SUH SAECHAN Date: Mon, 23 Feb 2026 14:41:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20origin/main=20=EB=A8=B8=EC=A7=80=20?= =?UTF-8?q?=ED=9B=84=20MemberPlace.folder=20=ED=95=84=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BookmarkDto: .folder(memberPlace.getFolder()) 참조 제거 - MemberPlaceService: updateFolder() 호출 블록 제거 - UpdateBookmarkRequest: folder 필드 없이 생성 (memo, rating, visited, visitedAt) - MemberPlaceRepository: 북마크 관련 쿼리 메서드 추가 - ErrorCode: CANNOT_UPDATE_UNSAVED_PLACE 추가 Co-Authored-By: Claude Opus 4.6 --- .../common/exception/constant/ErrorCode.java | 2 + .../mapsy/place/dto/BookmarkDto.java | 55 +++++++++ .../place/dto/UpdateBookmarkRequest.java | 28 +++++ .../repository/MemberPlaceRepository.java | 37 ++++++ .../place/service/MemberPlaceService.java | 113 ++++++++++++++++++ 5 files changed, 235 insertions(+) create mode 100644 MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/BookmarkDto.java create mode 100644 MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateBookmarkRequest.java diff --git a/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java b/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java index 3a1cced..044746a 100644 --- a/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java +++ b/MS-Common/src/main/java/kr/suhsaechan/mapsy/common/exception/constant/ErrorCode.java @@ -114,6 +114,8 @@ public enum ErrorCode { CANNOT_DELETE_SAVED_PLACE(HttpStatus.BAD_REQUEST, "임시 저장된 장소만 삭제할 수 있습니다."), + CANNOT_UPDATE_UNSAVED_PLACE(HttpStatus.BAD_REQUEST, "저장된 장소만 수정할 수 있습니다."), + INVALID_RATING(HttpStatus.BAD_REQUEST, "별점은 1-5 사이의 값이어야 합니다."), // Folder diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/BookmarkDto.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/BookmarkDto.java new file mode 100644 index 0000000..8d36ea9 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/BookmarkDto.java @@ -0,0 +1,55 @@ +package kr.suhsaechan.mapsy.place.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.UUID; +import kr.suhsaechan.mapsy.place.entity.MemberPlace; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "북마크 DTO") +public class BookmarkDto { + + @Schema(description = "MemberPlace ID", example = "550e8400-e29b-41d4-a716-446655440000") + private UUID memberPlaceId; + + @Schema(description = "장소 정보") + private PlaceDto place; + + @Schema(description = "메모", example = "친구랑 같이 가기") + private String memo; + + @Schema(description = "별점 (1-5)", example = "4") + private Integer rating; + + @Schema(description = "방문 여부", example = "false") + private Boolean visited; + + @Schema(description = "방문 일시") + private LocalDateTime visitedAt; + + @Schema(description = "저장 일시") + private LocalDateTime savedAt; + + public static BookmarkDto from(MemberPlace memberPlace) { + if (memberPlace == null) { + return null; + } + + return BookmarkDto.builder() + .memberPlaceId(memberPlace.getId()) + .place(PlaceDto.from(memberPlace.getPlace())) + .memo(memberPlace.getMemo()) + .rating(memberPlace.getRating()) + .visited(memberPlace.getVisited()) + .visitedAt(memberPlace.getVisitedAt()) + .savedAt(memberPlace.getSavedAt()) + .build(); + } +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateBookmarkRequest.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateBookmarkRequest.java new file mode 100644 index 0000000..abe2b10 --- /dev/null +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/dto/UpdateBookmarkRequest.java @@ -0,0 +1,28 @@ +package kr.suhsaechan.mapsy.place.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "북마크 수정 요청") +public class UpdateBookmarkRequest { + + @Schema(description = "메모 (null이면 변경하지 않음)", example = "친구랑 같이 가기") + private String memo; + + @Schema(description = "별점 1-5 (null이면 변경하지 않음)", example = "4") + private Integer rating; + + @Schema(description = "방문 여부 (null이면 변경하지 않음)", example = "true") + private Boolean visited; + + @Schema(description = "방문 일시 (null이면 visited=true 시 현재 시간)") + private LocalDateTime visitedAt; +} diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/MemberPlaceRepository.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/MemberPlaceRepository.java index 04bb124..b393500 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/MemberPlaceRepository.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/MemberPlaceRepository.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +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; @@ -70,4 +72,39 @@ List findByMemberAndSavedStatusWithPlace( @Param("member") Member member, @Param("savedStatus") PlaceSavedStatus savedStatus ); + + @Query(value = "SELECT mp FROM MemberPlace mp " + + "JOIN FETCH mp.place " + + "WHERE mp.member.id = :memberId " + + "AND mp.savedStatus = :savedStatus " + + "AND mp.deletedAt IS NULL", + countQuery = "SELECT COUNT(mp) FROM MemberPlace mp " + + "WHERE mp.member.id = :memberId " + + "AND mp.savedStatus = :savedStatus " + + "AND mp.deletedAt IS NULL") + Page findBookmarksByMemberIdAndSavedStatus( + @Param("memberId") UUID memberId, + @Param("savedStatus") PlaceSavedStatus savedStatus, + Pageable pageable + ); + + @Query("SELECT mp FROM MemberPlace mp " + + "WHERE mp.id = :id " + + "AND mp.member.id = :memberId " + + "AND mp.deletedAt IS NULL") + Optional findByIdAndMemberIdAndDeletedAtIsNull( + @Param("id") UUID id, + @Param("memberId") UUID memberId + ); + + @Query("SELECT mp FROM MemberPlace mp " + + "JOIN FETCH mp.place " + + "WHERE mp.member.id = :memberId " + + "AND mp.savedStatus = :savedStatus " + + "AND mp.deletedAt IS NULL") + List findTopPlacesByMemberIdAndSavedStatus( + @Param("memberId") UUID memberId, + @Param("savedStatus") PlaceSavedStatus savedStatus, + Pageable pageable + ); } diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/MemberPlaceService.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/MemberPlaceService.java index f3d55a4..3d9382d 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/MemberPlaceService.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/service/MemberPlaceService.java @@ -5,10 +5,12 @@ import kr.suhsaechan.mapsy.member.entity.Member; import kr.suhsaechan.mapsy.member.repository.MemberRepository; import kr.suhsaechan.mapsy.place.constant.PlaceSavedStatus; +import kr.suhsaechan.mapsy.place.dto.BookmarkDto; import kr.suhsaechan.mapsy.place.dto.GetSavedPlacesResponse; import kr.suhsaechan.mapsy.place.dto.GetTemporaryPlacesResponse; import kr.suhsaechan.mapsy.place.dto.PlaceDto; import kr.suhsaechan.mapsy.place.dto.SavePlaceResponse; +import kr.suhsaechan.mapsy.place.dto.UpdateBookmarkRequest; import kr.suhsaechan.mapsy.place.entity.MemberPlace; import kr.suhsaechan.mapsy.place.entity.Place; import kr.suhsaechan.mapsy.place.repository.MemberPlaceRepository; @@ -18,6 +20,10 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,6 +33,8 @@ @Transactional(readOnly = true) public class MemberPlaceService { + private static final int DEFAULT_TOP_PLACES_LIMIT = 10; + private final MemberPlaceRepository memberPlaceRepository; private final PlaceRepository placeRepository; private final MemberRepository memberRepository; @@ -206,6 +214,111 @@ public void deleteTemporaryPlace(UUID memberId, UUID placeId) { deleteTemporaryPlace(member, placeId); } + // ========== 북마크 관련 메서드 ========== + + /** + * 북마크 목록 조회 (페이지네이션) + * + * @param memberId 회원 ID + * @param pageable 페이지 정보 + * @return 북마크 Page + */ + public Page getBookmarks(UUID memberId, Pageable pageable) { + log.info("Getting bookmarks for member: {}, page: {}, size: {}", + memberId, pageable.getPageNumber(), pageable.getPageSize()); + + Page memberPlaces = memberPlaceRepository.findBookmarksByMemberIdAndSavedStatus( + memberId, PlaceSavedStatus.SAVED, pageable + ); + + log.info("Found {} bookmarks for member: {}", memberPlaces.getTotalElements(), memberId); + + return memberPlaces.map(BookmarkDto::from); + } + + /** + * 북마크 수정 + * - 폴더, 메모, 별점, 방문 여부 수정 가능 + * - null 값은 변경하지 않음 + * + * @param memberId 회원 ID + * @param memberPlaceId MemberPlace ID + * @param request 수정 요청 + * @return 수정된 북마크 정보 + */ + @Transactional + public BookmarkDto updateBookmark(UUID memberId, UUID memberPlaceId, UpdateBookmarkRequest request) { + log.info("Updating bookmark: memberId={}, memberPlaceId={}", memberId, memberPlaceId); + + // 1. MemberPlace 조회 (회원 검증 포함) + MemberPlace memberPlace = memberPlaceRepository.findByIdAndMemberIdAndDeletedAtIsNull( + memberPlaceId, memberId + ).orElseThrow(() -> { + log.error("MemberPlace not found: memberPlaceId={}, memberId={}", memberPlaceId, memberId); + return new CustomException(ErrorCode.MEMBER_PLACE_NOT_FOUND); + }); + + // 2. SAVED 상태만 수정 가능 + if (memberPlace.getSavedStatus() != PlaceSavedStatus.SAVED) { + log.error("Cannot update unsaved place: memberPlaceId={}, status={}", + memberPlaceId, memberPlace.getSavedStatus()); + throw new CustomException(ErrorCode.CANNOT_UPDATE_UNSAVED_PLACE); + } + + // 3. 필드별 수정 (null이 아닌 경우만) + if (request.getMemo() != null) { + memberPlace.updateMemo(request.getMemo()); + } + if (request.getRating() != null) { + memberPlace.updateRating(request.getRating()); + } + if (request.getVisited() != null) { + if (request.getVisited()) { + memberPlace.markAsVisited(request.getVisitedAt()); + } else { + memberPlace.unmarkVisited(); + } + } + + MemberPlace savedMemberPlace = memberPlaceRepository.save(memberPlace); + log.info("Bookmark updated successfully: memberPlaceId={}", savedMemberPlace.getId()); + + return BookmarkDto.from(savedMemberPlace); + } + + /** + * 내 TOP 저장 장소 조회 + * + * @param memberId 회원 ID + * @param limit 조회 개수 (기본 10개) + * @return 장소 목록 + */ + public List getMyTopPlaces(UUID memberId, int limit) { + log.info("Getting top {} saved places for member: {}", limit, memberId); + + Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "savedAt")); + List memberPlaces = memberPlaceRepository.findTopPlacesByMemberIdAndSavedStatus( + memberId, PlaceSavedStatus.SAVED, pageable + ); + + log.info("Found {} top places for member: {}", memberPlaces.size(), memberId); + + return memberPlaces.stream() + .map(MemberPlace::getPlace) + .map(PlaceDto::from) + .collect(Collectors.toList()); + } + + /** + * 내 TOP 저장 장소 조회 (기본 개수) + * + * @param memberId 회원 ID + * @return 장소 목록 (최대 10개) + */ + public List getMyTopPlaces(UUID memberId) { + return getMyTopPlaces(memberId, DEFAULT_TOP_PLACES_LIMIT); + } + // ========== Private Helper Methods ========== /**