diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java index 1577ea2..996db6a 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java @@ -1,9 +1,10 @@ package com.web.SearchWeb.bookmark.controller; +import com.web.SearchWeb.bookmark.controller.dto.BookmarkRequests; import com.web.SearchWeb.bookmark.domain.Bookmark; -import com.web.SearchWeb.bookmark.dto.BookmarkDto; import com.web.SearchWeb.bookmark.service.BookmarkService; +import com.web.SearchWeb.config.ApiResponse; import com.web.SearchWeb.member.dto.CustomOAuth2User; import com.web.SearchWeb.member.dto.CustomUserDetails; import org.springframework.beans.factory.annotation.Autowired; @@ -14,9 +15,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.web.bind.annotation.*; -import java.util.HashMap; import java.util.List; -import java.util.Map; /** @@ -34,98 +33,86 @@ public BookmarkApiController(BookmarkService bookmarkService) { this.bookmarkService = bookmarkService; } - /** - * 북마크 확인 - */ - @GetMapping("/check") - public ResponseEntity checkBookmark(@AuthenticationPrincipal Object currentUser, @RequestParam String url) { - // 로그인 되지 않은 경우 - if (currentUser == null || "anonymousUser".equals(currentUser)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - - Long memberId = getMemberId(currentUser); - - // 북마크 존재 여부 확인 - boolean exists = bookmarkService.checkBookmarkExistsByUrl(memberId, url); - return ResponseEntity.ok(exists); - } - - /** * 북마크 추가 */ @PostMapping - public ResponseEntity> insertBookmark( + public ResponseEntity> insertBookmark( @AuthenticationPrincipal Object currentUser, - @RequestBody BookmarkDto bookmarkDto, - @RequestParam String url) { - - Map response = new HashMap<>(); - + @RequestBody BookmarkRequests.CreateDto request) { + + // TODO: AOP 처리 // 로그인 되지 않은 경우 if (currentUser == null || "anonymousUser".equals(currentUser)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } Long memberId = getMemberId(currentUser); if (memberId == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - bookmarkDto.setCreatedByMemberId(memberId); - int result = bookmarkService.insertBookmark(bookmarkDto, url); - response.put("success", result > 0); - return ResponseEntity.ok(response); + Long bookmarkId = bookmarkService.insertBookmark( + memberId, + request.url, + request.memberFolderId, + request.displayTitle, + request.note, + request.primaryCategoryId, + request.tags + ); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(ApiResponse.success(bookmarkId)); } /** - * 북마크 목록 조회 + * 북마크 단일 조회 */ - @GetMapping - public ResponseEntity> selectBookmarkList( + @GetMapping("/{bookmarkId}") + public ResponseEntity> selectBookmark( @AuthenticationPrincipal Object currentUser, - @RequestParam(required = false) Long folderId, - @RequestParam(defaultValue = "Newest") String sort, - @RequestParam(required = false) String query, - @RequestParam(required = false) Long categoryId) { - + @PathVariable Long bookmarkId) { + + // TODO: AOP 처리 // 로그인 되지 않은 경우 if (currentUser == null || "anonymousUser".equals(currentUser)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - + Long memberId = getMemberId(currentUser); if (memberId == null) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - - List bookmarks = bookmarkService.selectBookmarkList(memberId, folderId, sort, query, categoryId); - return ResponseEntity.ok(bookmarks); + + Bookmark bookmark = bookmarkService.selectBookmark(memberId, bookmarkId); + return ResponseEntity.ok(ApiResponse.success(bookmark)); } /** - * 북마크 단일 조회 + * 북마크 목록 조회 */ - @GetMapping("/{bookmarkId}") - public ResponseEntity selectBookmark( + @GetMapping + public ResponseEntity>> selectBookmarkList( @AuthenticationPrincipal Object currentUser, - @PathVariable Long bookmarkId) { - + @ModelAttribute BookmarkRequests.SearchDto searchDto) { + + // TODO: AOP 처리 // 로그인 되지 않은 경우 if (currentUser == null || "anonymousUser".equals(currentUser)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - + Long memberId = getMemberId(currentUser); if (memberId == null) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - - Bookmark bookmark = bookmarkService.selectBookmark(memberId, bookmarkId); - return ResponseEntity.ok(bookmark); + + List bookmarks = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId)); + return ResponseEntity.ok(ApiResponse.success(bookmarks)); } @@ -133,22 +120,32 @@ public ResponseEntity selectBookmark( * 북마크 수정 */ @PutMapping("/{bookmarkId}") - public ResponseEntity> updateBookmark( + public ResponseEntity> updateBookmark( @AuthenticationPrincipal Object currentUser, @PathVariable Long bookmarkId, - @RequestBody BookmarkDto bookmarkDto) { + @RequestBody BookmarkRequests.UpdateDto request) { - Map response = new HashMap<>(); + // TODO: AOP 처리 + // 로그인 되지 않은 경우 + if (currentUser == null || "anonymousUser".equals(currentUser)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } Long memberId = getMemberId(currentUser); if (memberId == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - bookmarkDto.setCreatedByMemberId(memberId); - int result = bookmarkService.updateBookmark(bookmarkDto, bookmarkId); - response.put("success", result > 0); - return ResponseEntity.ok(response); + Long updatedBookmarkId = bookmarkService.updateBookmark( + memberId, + bookmarkId, + request.memberFolderId, + request.displayTitle, + request.note, + request.primaryCategoryId, + request.tags + ); + return ResponseEntity.ok(ApiResponse.success(updatedBookmarkId)); } @@ -156,20 +153,41 @@ public ResponseEntity> updateBookmark( * 북마크 삭제 */ @DeleteMapping("/{bookmarkId}") - public ResponseEntity> deleteBookmark( + public ResponseEntity> deleteBookmark( @AuthenticationPrincipal Object currentUser, @PathVariable Long bookmarkId) { - Map response = new HashMap<>(); - + // TODO: AOP 처리 + // 로그인 되지 않은 경우 + if (currentUser == null || "anonymousUser".equals(currentUser)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + Long memberId = getMemberId(currentUser); if (memberId == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - int result = bookmarkService.deleteBookmark(memberId, bookmarkId); - response.put("success", result > 0); - return ResponseEntity.ok(response); + Long deletedId = bookmarkService.deleteBookmark(memberId, bookmarkId); + return ResponseEntity.ok(ApiResponse.success(deletedId)); + } + + + /** + * 북마크 확인 + */ + @GetMapping("/check") + public ResponseEntity checkBookmark(@AuthenticationPrincipal Object currentUser, @RequestParam String url) { + // 로그인 되지 않은 경우 + if (currentUser == null || "anonymousUser".equals(currentUser)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + Long memberId = getMemberId(currentUser); + + // 북마크 존재 여부 확인 + boolean exists = bookmarkService.checkBookmarkExistsByUrl(memberId, url); + return ResponseEntity.ok(exists); } diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java new file mode 100644 index 0000000..56e248c --- /dev/null +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java @@ -0,0 +1,68 @@ +package com.web.SearchWeb.bookmark.controller.dto; + +import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Bookmark Controller 전용 Request DTO + * - Controller에서만 사용하며, Service 계층에는 개별 파라미터로 전달 + */ +public class BookmarkRequests { + + /** + * 북마크 생성 요청 Dto + */ + public static class CreateDto { + public Long bookmarkId; // 북마크 ID (PK, Insert 시 생성된 키 저장용) + public Long memberFolderId; // 폴더 ID (null이면 기본 폴더) + public String displayTitle; // 표시 제목 + public String url; // 저장할 URL + public String note; // 메모 + public Long primaryCategoryId; // 카테고리 ID + public Long createdByMemberId; // 저장한 회원 + public String tags; // 태그 문자열 (공백/콤마 구분) + } + + + /** + * 북마크 목록 조회 요청 Dto (Search Params) + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SearchDto { + public Long folderId; + public String sort = "Newest"; // 기본값 설정 + public String query; + public Long categoryId; + + public BookmarkSearchCommand toCommand(Long memberId) { + return BookmarkSearchCommand.builder() + .memberId(memberId) + .folderId(this.folderId) + .sort(this.sort) + .query(this.query) + .categoryId(this.categoryId) + .build(); + } + } + + + /** + * 북마크 수정 요청 Dto + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class UpdateDto { + public Long memberFolderId; // 폴더 ID + public String displayTitle; // 표시 제목 + public String note; // 메모 + public Long primaryCategoryId; // 카테고리 ID + public String tags; // 태그 문자열 (공백/콤마 구분) + } + +} diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java index 0456267..066ad26 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java +++ b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java @@ -2,39 +2,48 @@ import com.web.SearchWeb.bookmark.domain.Bookmark; import com.web.SearchWeb.bookmark.domain.Link; -import com.web.SearchWeb.bookmark.dto.BookmarkDto; -import com.web.SearchWeb.bookmark.dto.request.BookmarkSearchRequestDto; +import com.web.SearchWeb.bookmark.dto.MemberTagResultDto; +import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand; import java.util.List; public interface BookmarkDao { - //북마크 링크 중복 확인 (동일 폴더에 동일 링크) - int checkBookmarkExists(Long memberId, Long folderId, Long linkId); - //북마크 추가 - int insertBookmark(BookmarkDto bookmark, Long linkId); + int insertBookmark(Bookmark bookmark); //북마크 단일 조회 Bookmark selectBookmark(Long memberId, Long bookmarkId); //북마크 목록 조회 - List selectBookmarkList(BookmarkSearchRequestDto searchRequest); + List selectBookmarkList(BookmarkSearchCommand searchCommand); //북마크 수정 - int updateBookmark(BookmarkDto bookmarkDto, Long bookmarkId); - + int updateBookmark(Bookmark bookmark); + //북마크 삭제 int deleteBookmark(Long memberId, Long bookmarkId); + //북마크 태그 연결 삭제 + int deleteBookmarkTags(Long bookmarkId, Long memberId); + //북마크 삭제 (Link ID 기반 - soft delete) int deleteBookmarkByLink(Long memberId, Long linkId); - //링크 조회 (canonical_url로) - Link selectLinkByCanonicalUrl(String canonicalUrl); + //북마크 링크 중복 확인 (동일 폴더에 동일 링크) + int checkBookmarkExists(Long memberId, Long folderId, Long linkId); + + //링크 조회 (url로) + Link selectLinkByUrl(String url); //링크 추가 (link 테이블) int insertLink(Link link); //URL 기반 북마크 존재 여부 확인 (Board Bridge용) int checkBookmarkExistsByUrl(Long memberId, String url); + + // 태그 등록 및 조회 (Insert & Select) + List insertAndSelectTags(Long memberId, List tagNames); + + // 북마크-태그 연결 일괄 추가 (Bulk Insert) + int insertBookmarkTags(Long bookmarkId, List tagIds); } diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java index ab8e678..221cdbc 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java +++ b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java @@ -2,8 +2,8 @@ import com.web.SearchWeb.bookmark.domain.Bookmark; import com.web.SearchWeb.bookmark.domain.Link; -import com.web.SearchWeb.bookmark.dto.BookmarkDto; -import com.web.SearchWeb.bookmark.dto.request.BookmarkSearchRequestDto; +import com.web.SearchWeb.bookmark.dto.MemberTagResultDto; +import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand; import org.apache.ibatis.session.SqlSession; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; @@ -22,90 +22,118 @@ public MybatisBookmarkDao(SqlSession sqlSession) { /** - * 북마크 중복 확인 + * 북마크 추가 */ @Override - public int checkBookmarkExists(Long memberId, Long folderId, Long linkId) { - return mapper.checkBookmarkExists(memberId, folderId, linkId); + public int insertBookmark(Bookmark bookmark) { + return mapper.insertBookmark(bookmark); } /** - * URL 기반 북마크 존재 여부 확인 (Board Bridge용) + * 북마크 단일 조회 */ @Override - public int checkBookmarkExistsByUrl(Long memberId, String url) { - return mapper.checkBookmarkExistsByUrl(memberId, url); + public Bookmark selectBookmark(Long memberId, Long bookmarkId) { + return mapper.selectBookmark(memberId, bookmarkId); } /** - * 북마크 단일 조회 + * 북마크 목록 조회 */ @Override - public Bookmark selectBookmark(Long memberId, Long bookmarkId) { - return mapper.selectBookmark(memberId, bookmarkId); + public List selectBookmarkList(BookmarkSearchCommand searchCommand) { + return mapper.selectBookmarkList(searchCommand); } /** - * 북마크 목록 조회 + * 북마크 수정 */ @Override - public List selectBookmarkList(BookmarkSearchRequestDto searchRequest) { - return mapper.selectBookmarkList(searchRequest); + public int updateBookmark(Bookmark bookmark) { + return mapper.updateBookmark(bookmark); } /** - * 링크 조회 (canonical_url로) + * 북마크 삭제 (soft delete) */ @Override - public Link selectLinkByCanonicalUrl(String canonicalUrl) { - return mapper.selectLinkByCanonicalUrl(canonicalUrl); + public int deleteBookmark(Long memberId, Long bookmarkId) { + return mapper.deleteBookmark(memberId, bookmarkId); } /** - * 링크 추가 + * 북마크 태그 연결 삭제 */ @Override - public int insertLink(Link link) { - return mapper.insertLink(link); + public int deleteBookmarkTags(Long bookmarkId, Long memberId) { + return mapper.deleteBookmarkTags(bookmarkId, memberId); } /** - * 북마크 추가 + * 북마크 삭제 (Link ID 기반 - soft delete) */ @Override - public int insertBookmark(BookmarkDto bookmark, Long linkId) { - return mapper.insertBookmark(bookmark, linkId); + public int deleteBookmarkByLink(Long memberId, Long linkId) { + return mapper.deleteBookmarkByLink(memberId, linkId); } /** - * 북마크 수정 + * 북마크 중복 확인 */ @Override - public int updateBookmark(BookmarkDto bookmarkDto, Long bookmarkId) { - return mapper.updateBookmark(bookmarkDto, bookmarkId); + public int checkBookmarkExists(Long memberId, Long folderId, Long linkId) { + return mapper.checkBookmarkExists(memberId, folderId, linkId); } /** - * 북마크 삭제 (soft delete) + * URL 기반 북마크 존재 여부 확인 (Board Bridge용) */ @Override - public int deleteBookmark(Long memberId, Long bookmarkId) { - return mapper.deleteBookmark(memberId, bookmarkId); + public int checkBookmarkExistsByUrl(Long memberId, String url) { + return mapper.checkBookmarkExistsByUrl(memberId, url); } + /** - * 북마크 삭제 (Link ID 기반 - soft delete) + * 링크 조회 (url로) */ @Override - public int deleteBookmarkByLink(Long memberId, Long linkId) { - return mapper.deleteBookmarkByLink(memberId, linkId); + public Link selectLinkByUrl(String url) { + return mapper.selectLinkByUrl(url); + } + + + /** + * 링크 추가 + */ + @Override + public int insertLink(Link link) { + return mapper.insertLink(link); + } + + + /** + * 태그 등록 및 조회 (Insert & Select) + */ + @Override + public List insertAndSelectTags(Long memberId, List tagNames) { + return mapper.insertAndSelectTags(memberId, tagNames); + } + + + /** + * 북마크-태그 연결 일괄 추가 (Bulk Insert) + */ + @Override + public int insertBookmarkTags(Long bookmarkId, List tagIds) { + return mapper.insertBookmarkTags(bookmarkId, tagIds); } } diff --git a/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java b/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java index 3d8de31..0dd050b 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java +++ b/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java @@ -4,9 +4,12 @@ import java.util.List; import com.web.SearchWeb.common.domain.BaseEntity; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; +import lombok.experimental.SuperBuilder; /** * Bookmark 도메인 클래스 (MemberSavedLink) @@ -16,6 +19,9 @@ */ @Getter @Setter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor @ToString(callSuper = true) public class Bookmark extends BaseEntity { private Long bookmarkId; // 북마크 고유 ID (PK, member_saved_link_id) @@ -30,4 +36,7 @@ public class Bookmark extends BaseEntity { // Link 객체 (Association) private Link link; // Link 테이블과 조인된 객체 + + // Tag 목록 (Collection) + private List tags; // 태그 목록 } diff --git a/src/main/java/com/web/SearchWeb/bookmark/dto/BookmarkDto.java b/src/main/java/com/web/SearchWeb/bookmark/dto/BookmarkDto.java index baddaa5..ecf991f 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dto/BookmarkDto.java +++ b/src/main/java/com/web/SearchWeb/bookmark/dto/BookmarkDto.java @@ -19,10 +19,12 @@ @AllArgsConstructor @Builder public class BookmarkDto { + private Long bookmarkId; // bookmark_id (PK, Insert 시 생성된 키 저장용) private Long memberFolderId; // member_folder_id (저장할 폴더) private String displayTitle; // display_title (사용자가 지정한 제목) private String url; // original_url (link 테이블에 저장) private String note; // note (메모) private Long primaryCategoryId; // primary_category_id (카테고리) private Long createdByMemberId; // created_by_member_id (저장한 회원) + private String tags; // Space-separated tags ex: "dev java spring" } diff --git a/src/main/java/com/web/SearchWeb/bookmark/dto/MemberTagResultDto.java b/src/main/java/com/web/SearchWeb/bookmark/dto/MemberTagResultDto.java new file mode 100644 index 0000000..40c12ef --- /dev/null +++ b/src/main/java/com/web/SearchWeb/bookmark/dto/MemberTagResultDto.java @@ -0,0 +1,13 @@ +package com.web.SearchWeb.bookmark.dto; + +import lombok.Data; + +/** + * 북마크 태그 등록 및 조회(insertAndSelectTags) 결과를 담는 DTO + * - 새로 생성된 태그 또는 기존에 존재하는 태그의 ID와 이름을 다 포함 + */ +@Data +public class MemberTagResultDto { + private Long memberTagId; + private String tagName; +} diff --git a/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java b/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java new file mode 100644 index 0000000..db718f4 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java @@ -0,0 +1,17 @@ +package com.web.SearchWeb.bookmark.error; + +import com.web.SearchWeb.config.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BookmarkErrorCode implements ErrorCode { + DUPLICATE_BOOKMARK(HttpStatus.CONFLICT, "B001", "이미 존재하는 북마크입니다."), + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "북마크를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java index 78bd6ee..256235c 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java @@ -5,24 +5,27 @@ import com.web.SearchWeb.bookmark.dto.BoardBookmarkCheckDto; import com.web.SearchWeb.bookmark.dto.BookmarkDto; +import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand; import java.util.List; public interface BookmarkService { //북마크 추가 - int insertBookmark(BookmarkDto bookmarkDto, String url); + Long insertBookmark(Long memberId, String url, Long memberFolderId, String displayTitle, + String note, Long primaryCategoryId, String tags); //북마크 단일 조회 Bookmark selectBookmark(Long memberId, Long bookmarkId); //북마크 목록 조회 - List selectBookmarkList(Long memberId, Long folderId, String sort, String query, Long categoryId); + List selectBookmarkList(BookmarkSearchCommand command); //북마크 수정 - int updateBookmark(BookmarkDto bookmarkDto, Long bookmarkId); + Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String displayTitle, + String note, Long primaryCategoryId, String tags); //북마크 삭제 - int deleteBookmark(Long memberId, Long bookmarkId); + Long deleteBookmark(Long memberId, Long bookmarkId); // 링크 조회 또는 생성 (URL 정규화) Link getOrCreateLink(String url, Long createdByMemberId); diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java index a76737d..d570af1 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java @@ -5,15 +5,29 @@ import com.web.SearchWeb.bookmark.domain.Link; import com.web.SearchWeb.bookmark.dto.BoardBookmarkCheckDto; import com.web.SearchWeb.bookmark.dto.BookmarkDto; -import com.web.SearchWeb.bookmark.dto.request.BookmarkSearchRequestDto; +import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand; + +import lombok.extern.slf4j.Slf4j; + import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.web.SearchWeb.bookmark.error.BookmarkErrorCode; +import com.web.SearchWeb.config.BusinessException; +import com.web.SearchWeb.config.CommonErrorCode; + +import com.web.SearchWeb.bookmark.dto.MemberTagResultDto; import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; @Service +@Slf4j public class BookmarkServiceImpl implements BookmarkService { private final BookmarkDao bookmarkDao; @@ -24,12 +38,62 @@ public BookmarkServiceImpl(BookmarkDao bookmarkDao) { } + /** + * 북마크 추가 + */ + @Override + @Transactional + public Long insertBookmark(Long memberId, String url, Long memberFolderId, String displayTitle, + String note, Long primaryCategoryId, String tags) { + // 링크 조회 또는 생성 + Link link = getOrCreateLink(url, memberId); + + // TODO: 링크 분석 및 폴더 서비스 완성 후 제거 - 임시 기본 폴더 ID 설정 + if (memberFolderId == null) { + memberFolderId = 1L; // 임시 하드코딩 값 + log.warn("memberFolderId가 null이어서 임시 기본값(1)을 사용합니다. 링크 분석 및 폴더 서비스 연동 후 제거 필요."); + } + + // Entity 생성 + Bookmark bookmark = Bookmark.builder() + .linkId(link.getLinkId()) + .memberFolderId(memberFolderId) + .displayTitle(displayTitle) + .note(note) + .primaryCategoryId(primaryCategoryId) + .createdByMemberId(memberId) + .build(); + + // 북마크 추가 + try { + int result = bookmarkDao.insertBookmark(bookmark); + + // 태그 처리 및 저장 + if (result > 0) { + if (tags != null && !tags.isEmpty()) { + // MyBatis의 useGeneratedKeys="true" 설정에 의해 insert 성공 시, bookmark.bookmarkId에 생성된 PK가 자동으로 채워짐 + processAndCreateTags(bookmark.getBookmarkId(), memberId, tags); + } + return bookmark.getBookmarkId(); + } + throw BusinessException.from(CommonErrorCode.INTERNAL_SERVER_ERROR); + } catch (DataIntegrityViolationException e) { + log.warn("북마크 중복 저장 시도: memberId={}, folderId={}, linkId={}", memberId, memberFolderId, link.getLinkId()); + throw BusinessException.from(BookmarkErrorCode.DUPLICATE_BOOKMARK); + } + } + + /** * 북마크 단일 조회 */ @Override public Bookmark selectBookmark(Long memberId, Long bookmarkId) { - return bookmarkDao.selectBookmark(memberId, bookmarkId); + Bookmark bookmark = bookmarkDao.selectBookmark(memberId, bookmarkId); + if (bookmark == null) { + throw BusinessException.from(BookmarkErrorCode.BOOKMARK_NOT_FOUND); + } + return bookmark; } @@ -37,18 +101,78 @@ public Bookmark selectBookmark(Long memberId, Long bookmarkId) { * 북마크 목록 조회 */ @Override - public List selectBookmarkList(Long memberId, Long folderId, String sort, String query, Long categoryId) { - BookmarkSearchRequestDto searchRequest = BookmarkSearchRequestDto.builder() - .memberId(memberId) - .folderId(folderId) - .sort(sort) - .query(query) - .categoryId(categoryId) - .build(); - return bookmarkDao.selectBookmarkList(searchRequest); + public List selectBookmarkList(BookmarkSearchCommand command) { + return bookmarkDao.selectBookmarkList(command); } + /** + * 북마크 수정 + */ + @Override + @Transactional + public Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String displayTitle, + String note, Long primaryCategoryId, String tags) { + try { + // 1. Entity 생성 (도메인 생성 로직을 서비스 계층으로 이동) + Bookmark bookmark = Bookmark.builder() + .bookmarkId(bookmarkId) + .memberFolderId(memberFolderId) + .displayTitle(displayTitle) + .note(note) + .primaryCategoryId(primaryCategoryId) + .createdByMemberId(memberId) + .build(); + + // 2. 북마크 기본 정보 수정 + int result = bookmarkDao.updateBookmark(bookmark); + + if (result == 0) { + throw BusinessException.from(BookmarkErrorCode.BOOKMARK_NOT_FOUND); + } + + // 3. 태그 수정 + if (tags != null) { + // 기존 태그 관계 삭제 (Soft Delete) + bookmarkDao.deleteBookmarkTags(bookmarkId, memberId); + + // 새 태그 등록 및 관계 생성/재활성화 (빈 문자열이면 모든 태그 제거) + if (!tags.isBlank()) { + processAndCreateTags(bookmarkId, memberId, tags); + } + } + + return bookmarkId; + } catch (DataIntegrityViolationException e) { + log.error("북마크 수정 중 데이터 무결성 위반: bookmarkId={}, memberId={}", bookmarkId, memberId, e); + throw BusinessException.from(BookmarkErrorCode.DUPLICATE_BOOKMARK); + } + } + + + /** + * 북마크 삭제 (soft delete) + */ + @Override + @Transactional + public Long deleteBookmark(Long memberId, Long bookmarkId) { + // 1. 북마크 삭제 (Soft Delete) + int result = bookmarkDao.deleteBookmark(memberId, bookmarkId); + + if (result == 0) { + throw BusinessException.from(BookmarkErrorCode.BOOKMARK_NOT_FOUND); + } + + // 2. 관련 태그 관계 삭제 (Soft Delete) + bookmarkDao.deleteBookmarkTags(bookmarkId, memberId); + + return bookmarkId; + } + + + + + // ========== Helper Methods ========== /** * 링크 조회 또는 생성 (URL 정규화) */ @@ -57,13 +181,13 @@ public List selectBookmarkList(Long memberId, Long folderId, String so public Link getOrCreateLink(String url, Long createdByMemberId) { // URL 정규화 (canonical URL 생성) String canonicalUrl = normalizeUrl(url); - + // 기존 링크 조회 - Link existingLink = bookmarkDao.selectLinkByCanonicalUrl(canonicalUrl); + Link existingLink = bookmarkDao.selectLinkByUrl(url); if (existingLink != null) { return existingLink; } - + // 새 링크 생성 Link newLink = Link.builder() .canonicalUrl(canonicalUrl) @@ -73,7 +197,7 @@ public Link getOrCreateLink(String url, Long createdByMemberId) { .primaryCategoryId(1L) // 기본 카테고리 .createdByMemberId(createdByMemberId) .build(); - + bookmarkDao.insertLink(newLink); return newLink; } @@ -86,7 +210,7 @@ public Link getOrCreateLink(String url, Long createdByMemberId) { public boolean checkBookmarkExistsByUrl(Long memberId, String url) { String canonicalUrl = normalizeUrl(url); // Link가 존재하는지 먼저 확인 (최적화) - Link link = bookmarkDao.selectLinkByCanonicalUrl(canonicalUrl); + Link link = bookmarkDao.selectLinkByUrl(url); if (link == null) { return false; } @@ -95,44 +219,6 @@ public boolean checkBookmarkExistsByUrl(Long memberId, String url) { } - /** - * 북마크 추가 - */ - @Override - @Transactional - public int insertBookmark(BookmarkDto bookmarkDto, String url) { - // 링크 조회 또는 생성 - Link link = getOrCreateLink(url, bookmarkDto.getCreatedByMemberId()); - - // 중복 확인 (기본 폴더 등에서) - int exists = bookmarkDao.checkBookmarkExists(bookmarkDto.getCreatedByMemberId(), bookmarkDto.getMemberFolderId(), link.getLinkId()); - if (exists > 0) { - return 0; // 이미 존재함 - } - - // 북마크 추가 - return bookmarkDao.insertBookmark(bookmarkDto, link.getLinkId()); - } - - - /** - * 북마크 수정 - */ - @Override - public int updateBookmark(BookmarkDto bookmarkDto, Long bookmarkId) { - return bookmarkDao.updateBookmark(bookmarkDto, bookmarkId); - } - - - /** - * 북마크 삭제 (soft delete) - */ - @Override - public int deleteBookmark(Long memberId, Long bookmarkId) { - return bookmarkDao.deleteBookmark(memberId, bookmarkId); - } - - /** * URL 정규화 (canonical URL 생성) */ @@ -164,7 +250,44 @@ private String extractDomain(String url) { } } + /** + * 태그 문자열 처리 및 저장 + * @param bookmarkId 북마크 ID + * @param memberId 회원 ID + * @param tags 태그 문자열 (띄어쓰기 또는 콤마 구분) + */ + private void processAndCreateTags(Long bookmarkId, Long memberId, String tags) { + if (tags == null || tags.isBlank()) return; + + // 1. 태그 파싱 및 중복 제거 + Set uniqueTags = new HashSet<>(); + String[] splitTags = tags.split("[,\\s]+"); + for (String tag : splitTags) { + if (!tag.isBlank()) { + uniqueTags.add(tag.trim()); + } + } + + if (uniqueTags.isEmpty()) return; + + List tagNames = new ArrayList<>(uniqueTags); + + // 2. 태그 등록 및 조회 (Insert & Select) - CTE를 사용하여 한 번의 쿼리로 처리 + // 새로운 태그는 생성하고, 기존 태그는 조회하여 모든 태그의 ID를 반환함 + List allTags = bookmarkDao.insertAndSelectTags(memberId, tagNames); + + // 최종 태그 ID 목록 추출 + List finalTagIds = allTags.stream() + .map(MemberTagResultDto::getMemberTagId) + .collect(Collectors.toList()); + + // 3. 북마크-태그 연결 일괄 추가 (Bulk Insert) + if (!finalTagIds.isEmpty()) { + bookmarkDao.insertBookmarkTags(bookmarkId, finalTagIds); + } + } + // ========== Legacy Board-Bookmark Methods ========== /** diff --git a/src/main/java/com/web/SearchWeb/bookmark/dto/request/BookmarkSearchRequestDto.java b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java similarity index 82% rename from src/main/java/com/web/SearchWeb/bookmark/dto/request/BookmarkSearchRequestDto.java rename to src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java index c4ffbd8..8ac2ce3 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dto/request/BookmarkSearchRequestDto.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java @@ -1,4 +1,4 @@ -package com.web.SearchWeb.bookmark.dto.request; +package com.web.SearchWeb.bookmark.service.command; import lombok.AllArgsConstructor; import lombok.Builder; @@ -6,13 +6,13 @@ import lombok.NoArgsConstructor; /** - * Bookmark 검색 요청 DTO + * Bookmark 검색 요청 Command */ @Getter @NoArgsConstructor @AllArgsConstructor @Builder -public class BookmarkSearchRequestDto { +public class BookmarkSearchCommand { private Long memberId; // created_by_member_id로 필터 private Long folderId; // member_folder_id로 필터 private String sort; // 정렬 기준 (Newest, Oldest) diff --git a/src/main/java/com/web/SearchWeb/config/BusinessException.java b/src/main/java/com/web/SearchWeb/config/BusinessException.java index 7aa60d6..42c7578 100644 --- a/src/main/java/com/web/SearchWeb/config/BusinessException.java +++ b/src/main/java/com/web/SearchWeb/config/BusinessException.java @@ -10,4 +10,8 @@ public BusinessException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } + + public static BusinessException from(ErrorCode errorCode) { + return new BusinessException(errorCode); + } } diff --git a/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java b/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java index 7bd34a4..215a849 100644 --- a/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java +++ b/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java @@ -1,9 +1,11 @@ package com.web.SearchWeb.mypage.controller; import com.web.SearchWeb.aop.OwnerCheck; +import com.web.SearchWeb.bookmark.controller.dto.BookmarkRequests; import com.web.SearchWeb.bookmark.domain.Bookmark; import com.web.SearchWeb.bookmark.dto.BookmarkDto; import com.web.SearchWeb.bookmark.service.BookmarkService; +import com.web.SearchWeb.config.ApiResponse; import com.web.SearchWeb.member.domain.Member; import com.web.SearchWeb.member.dto.MemberUpdateDto; import com.web.SearchWeb.member.service.MemberService; @@ -77,8 +79,18 @@ public ResponseEntity> insertBookmark(@PathVariable final Lo @RequestParam String url){ Map response = new HashMap<>(); bookmarkDto.setCreatedByMemberId(memberId); - int result = bookmarkService.insertBookmark(bookmarkDto, url); - response.put("success", result > 0); + + Long bookmarkId = bookmarkService.insertBookmark( + memberId, + url, + bookmarkDto.getMemberFolderId(), + bookmarkDto.getDisplayTitle(), + bookmarkDto.getNote(), + bookmarkDto.getPrimaryCategoryId(), + bookmarkDto.getTags() + ); + + response.put("success", bookmarkId != null && bookmarkId > 0); return ResponseEntity.ok(response); } @@ -89,13 +101,11 @@ public ResponseEntity> insertBookmark(@PathVariable final Lo */ @GetMapping(value ="/myPage/{memberId}/bookmarks") @OwnerCheck(idParam = "memberId", service = "memberService") - public ResponseEntity> getBookmarks(@PathVariable final Long memberId, - @RequestParam(required = false) String query, - @RequestParam(required = false) Long folderId, - @RequestParam(required = false) Long categoryId, - @RequestParam(defaultValue = "Oldest") String sort) { - List bookmarks = bookmarkService.selectBookmarkList(memberId, folderId, sort, query, categoryId); - return ResponseEntity.ok(bookmarks); + public ResponseEntity>> getBookmarks( + @PathVariable final Long memberId, + @ModelAttribute BookmarkRequests.SearchDto searchDto) { + List bookmarks = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId)); + return ResponseEntity.ok(ApiResponse.success(bookmarks)); } @@ -115,13 +125,21 @@ public ResponseEntity getBookmark(@PathVariable final Long memberId, @ */ @PutMapping("/myPage/{memberId}/bookmark/{bookmarkId}") @OwnerCheck(idParam = "memberId", service = "memberService") - public ResponseEntity> updateBookmark(@PathVariable final Long memberId, - @PathVariable final Long bookmarkId, - @RequestBody BookmarkDto bookmarkDto) { - Map response = new HashMap<>(); - int result = bookmarkService.updateBookmark(bookmarkDto, bookmarkId); - response.put("success", result > 0); - return ResponseEntity.ok(response); + public ResponseEntity> updateBookmark( + @PathVariable final Long memberId, + @PathVariable final Long bookmarkId, + @RequestBody BookmarkRequests.UpdateDto request) { + + Long updatedBookmarkId = bookmarkService.updateBookmark( + memberId, + bookmarkId, + request.memberFolderId, + request.displayTitle, + request.note, + request.primaryCategoryId, + request.tags + ); + return ResponseEntity.ok(ApiResponse.success(updatedBookmarkId)); } @@ -130,10 +148,8 @@ public ResponseEntity> updateBookmark(@PathVariable final Lo */ @DeleteMapping("/myPage/{memberId}/bookmark/{bookmarkId}") @OwnerCheck(idParam = "memberId", service = "memberService") - public ResponseEntity> deleteBookmark(@PathVariable final Long memberId, @PathVariable final Long bookmarkId) { - Map response = new HashMap<>(); - int result = bookmarkService.deleteBookmark(memberId, bookmarkId); - response.put("success", result > 0); - return ResponseEntity.ok(response); + public ResponseEntity> deleteBookmark(@PathVariable final Long memberId, @PathVariable final Long bookmarkId) { + Long deletedId = bookmarkService.deleteBookmark(memberId, bookmarkId); + return ResponseEntity.ok(ApiResponse.success(deletedId)); } } diff --git a/src/main/resources/mapper/bookmark-mapper.xml b/src/main/resources/mapper/bookmark-mapper.xml index c505a40..68ae781 100644 --- a/src/main/resources/mapper/bookmark-mapper.xml +++ b/src/main/resources/mapper/bookmark-mapper.xml @@ -25,6 +25,10 @@ + + + + @@ -61,32 +65,50 @@ - + SELECT msl.*, + l.original_url, l.canonical_url, l.domain, l.thumbnail_url, + mt.tag_name + FROM member_saved_link msl + JOIN link l + ON msl.link_id = l.link_id + LEFT JOIN member_saved_link_tag mslt + ON msl.member_saved_link_id = mslt.member_saved_link_id + AND mslt.deleted_at IS NULL + LEFT JOIN member_tag mt + ON mslt.member_tag_id = mt.member_tag_id + AND mt.deleted_at IS NULL + WHERE msl.created_by_member_id = #{memberId} + AND msl.deleted_at IS NULL - AND msl.member_folder_id = #{folderId} + AND msl.member_folder_id = #{folderId} - AND msl.primary_category_id = #{categoryId} + AND msl.primary_category_id = #{categoryId} - AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' + AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' - ORDER BY + ORDER BY msl.updated_at DESC @@ -101,10 +123,10 @@ - - SELECT * FROM link - WHERE canonical_url = #{canonicalUrl} + WHERE canonical_url = #{url} AND deleted_at IS NULL @@ -117,22 +139,32 @@ - + INSERT INTO member_saved_link (link_id, member_folder_id, display_title, note, primary_category_id, created_by_member_id) - VALUES (#{linkId}, #{bookmark.memberFolderId}, #{bookmark.displayTitle}, #{bookmark.note}, #{bookmark.primaryCategoryId}, #{bookmark.createdByMemberId}) + VALUES (#{linkId}, #{memberFolderId}, #{displayTitle}, #{note}, #{primaryCategoryId}, #{createdByMemberId}) - + UPDATE member_saved_link SET - display_title = #{bookmarkDto.displayTitle}, - note = #{bookmarkDto.note}, - member_folder_id = #{bookmarkDto.memberFolderId}, - primary_category_id = #{bookmarkDto.primaryCategoryId}, + display_title = #{displayTitle}, + note = #{note}, + member_folder_id = #{memberFolderId}, + primary_category_id = #{primaryCategoryId}, updated_at = now(), - updated_by_member_id = #{bookmarkDto.createdByMemberId} + updated_by_member_id = #{createdByMemberId} + WHERE member_saved_link_id = #{bookmarkId} + AND deleted_at IS NULL + + + + + + UPDATE member_saved_link_tag + SET deleted_at = now(), + deleted_by_member_id = #{memberId} WHERE member_saved_link_id = #{bookmarkId} AND deleted_at IS NULL @@ -148,4 +180,52 @@ AND deleted_at IS NULL + + + + + INSERT INTO member_tag (owner_member_id, tag_name) + VALUES (#{memberId}, #{tagName}) + ON CONFLICT (owner_member_id, tag_name) + DO UPDATE SET deleted_at = NULL, updated_at = now() + RETURNING member_tag_id + + + + + + + + + + INSERT INTO member_saved_link_tag (member_saved_link_id, member_tag_id) + VALUES + + (#{bookmarkId}, #{tagId}) + + ON CONFLICT (member_saved_link_id, member_tag_id) + DO UPDATE SET deleted_at = NULL, updated_at = now() + + diff --git a/src/main/resources/templates/mypage/myPage.html b/src/main/resources/templates/mypage/myPage.html index 5244cef..f5dd0d2 100644 --- a/src/main/resources/templates/mypage/myPage.html +++ b/src/main/resources/templates/mypage/myPage.html @@ -420,7 +420,7 @@

북마크 수정

member_memberId: memberId, displayTitle: name, // 웹사이트 이름 -> displayTitle note: description, // 웹사이트 설명 -> note - tag: tag, // 단일 태그 + tags: tag, // 단일 태그 -> tags로 변경 // memberFolderId: null, // 백엔드에서 처리하거나, 나중에 폴더 선택 기능 추가 시 설정 // primaryCategoryId: 1 // 기본 카테고리 }; @@ -477,9 +477,12 @@

북마크 수정

query: query // 검색어를 서버로 전달 }, success: function(response) { + // ApiResponse 구조 대응 (response.data) + const bookmarks = response.data || []; + // 콜백 함수 실행 (필요한 경우) if (callback) { - callback(response); // 콜백 함수 실행, response를 인자로 전달 + callback(bookmarks); // 콜백 함수 실행, response.data를 인자로 전달 return } @@ -489,12 +492,12 @@

북마크 수정

// 불러온 북마크가 없는 경우 빈 상태 메시지 표시 - if (response.length === 0) { + if (bookmarks.length === 0) { document.getElementById('emptyBookmarkMessage').classList.remove('hidden'); } else { document.getElementById('emptyBookmarkMessage').classList.add('hidden'); // 불러온 북마크를 UI에 추가 - response.forEach(addBookmarkToUI); + bookmarks.forEach(addBookmarkToUI); } @@ -678,7 +681,7 @@

${displayTitle}

displayTitle: name, url: url, // DTO has url field note: description, - tag: tag, + tags: tag, memberFolderId: memberFolderId ? parseInt(memberFolderId) : null, primaryCategoryId: primaryCategoryId ? parseInt(primaryCategoryId) : 1, createdByMemberId: memberId // DTO 필드 @@ -745,7 +748,7 @@

${displayTitle}

- + // 사용자 프로필 수정 // 프로필 수정 폼 토글 함수 function toggleUpdateProfileForm() { document.getElementById('updateProfileForm').classList.toggle('hidden');