diff --git a/.github/workflows/dev-ci-cd.yml b/.github/workflows/dev-ci-cd.yml index c30caa8..af726fe 100644 --- a/.github/workflows/dev-ci-cd.yml +++ b/.github/workflows/dev-ci-cd.yml @@ -9,7 +9,7 @@ name: 키키하이 dev CI-CD 파이프라인 on: push: - branches: [ "develop","feat/product/KIKI-61-ElasticSearch"] + branches: [ "develop"] jobs: #1. 개발 서버 CI, Build 용 diff --git a/src/main/java/site/kikihi/custom/global/response/ErrorCode.java b/src/main/java/site/kikihi/custom/global/response/ErrorCode.java index 98376e1..5984e38 100644 --- a/src/main/java/site/kikihi/custom/global/response/ErrorCode.java +++ b/src/main/java/site/kikihi/custom/global/response/ErrorCode.java @@ -7,7 +7,7 @@ /** * 애플리케이션 전역에서 사용하는 에러 코드 Enum입니다. * 각 에러는 고유 코드, HTTP 상태, 메시지를 포함합니다. - * + *

* 400 : 잘못된 요청 에러 * 401 : 로그인 관련 에러 * 403 : 권한 부족 관련 에러 @@ -28,6 +28,8 @@ public enum ErrorCode { INVALID_INPUT(400_002, HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."), NULL_VALUE(400_003, HttpStatus.BAD_REQUEST, "Null 값이 들어왔습니다."), TEST_ERROR(400_004, HttpStatus.BAD_REQUEST, "테스트 에러입니다."), + ALREADY_ON(400_005, HttpStatus.BAD_REQUEST, "이미 검색어 저장 기능이 켜져있습니다"), + ALREADY_OFF(400_006, HttpStatus.BAD_REQUEST, "이미 검색어 저장 기능이 꺼져있습니다"), // ======================== @@ -48,7 +50,6 @@ public enum ErrorCode { TOKEN_NOT_FOUND_COOKIE(401_011, HttpStatus.UNAUTHORIZED, "쿠키에 리프레시 토큰이 존재하지 않습니다."), - // ======================== // 403 Forbidden // ======================== @@ -57,6 +58,7 @@ public enum ErrorCode { UNAUTHORIZED_POST_ACCESS(403_002, HttpStatus.FORBIDDEN, "해당 게시글에 접근할 권한이 없습니다."), UNAUTHORIZED_DELETE_BOOKMARK(403_003, HttpStatus.FORBIDDEN, "해당 북마크를 삭제할 권한이 없습니다."), UNAUTHORIZED_DELETE_CUSTOM(403_004, HttpStatus.FORBIDDEN, "해당 커스텀을 삭제할 권한이 없습니다."), + UNAUTHORIZED_DELETE_SEARCH(403_005, HttpStatus.FORBIDDEN, "검색기록을 삭제할 권한이 없습니다."), // ======================== @@ -69,20 +71,22 @@ public enum ErrorCode { POST_TYPE_NOT_FOUND(404_004, HttpStatus.NOT_FOUND, "게시글 타입을 찾을 수 없습니다."), COMMENT_NOT_FOUND(404_005, HttpStatus.NOT_FOUND, "요청한 댓글을 찾을 수 없습니다."), PRODUCT_NOT_FOUND(404_006, HttpStatus.NOT_FOUND, "요청한 상품을 찾을 수 없습니다."), - BOOKMARK_NOT_FOUND(404_007,HttpStatus.NOT_FOUND,"요청한 북마크를 찾을 수 없습니다."), - CUSTOM_NOT_FOUND(404_008,HttpStatus.NOT_FOUND,"요청한 커스텀 키보드를 찾을 수 없습니다."), + BOOKMARK_NOT_FOUND(404_007, HttpStatus.NOT_FOUND, "요청한 북마크를 찾을 수 없습니다."), + CUSTOM_NOT_FOUND(404_008, HttpStatus.NOT_FOUND, "요청한 커스텀 키보드를 찾을 수 없습니다."), + SEARCH_NOT_FOUND(404_009, HttpStatus.NOT_FOUND, "요청한 검색기록을 찾을 수 없습니다."), // ======================== // 409 Conflict // ======================== DUPLICATE_EMAIL(409_001, HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."), - BOOKMARK_ALREADY(409_003,HttpStatus.CONFLICT,"이미 해당 상품에 북마크를 등록했습니다"), + BOOKMARK_ALREADY(409_003, HttpStatus.CONFLICT, "이미 해당 상품에 북마크를 등록했습니다"), // ======================== // 500 Internal Server Error // ======================== - INTERNAL_SERVER_ERROR(500_000, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다."); + INTERNAL_SERVER_ERROR(500_000, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다."), + ; // 기타 공통 diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/DevAuthController.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/DevAuthController.java index cb91a59..96111e9 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/in/web/DevAuthController.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/DevAuthController.java @@ -74,6 +74,7 @@ private User createDev() { .provider(Provider.KAKAO) // 테스트용 값 (Enum) .role(Role.ADMIN) // 관리자 권한 부여 .address(Address.of()) + .isSearch(true) .build(); } diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/SearchController.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/SearchController.java index 5ca63ab..f8785ab 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/in/web/SearchController.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/SearchController.java @@ -1,44 +1,130 @@ package site.kikihi.custom.platform.adapter.in.web; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import site.kikihi.custom.global.response.ApiResponse; +import site.kikihi.custom.global.response.page.PageRequest; import site.kikihi.custom.platform.adapter.in.web.dto.response.product.ProductListResponse; -import site.kikihi.custom.platform.application.service.ElasticSearchService; +import site.kikihi.custom.platform.adapter.in.web.dto.response.search.SearchListResponse; +import site.kikihi.custom.platform.adapter.in.web.swagger.SearchControllerSpec; +import site.kikihi.custom.platform.application.in.search.SearchUseCase; import site.kikihi.custom.platform.domain.product.Product; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import site.kikihi.custom.platform.domain.search.Search; +import site.kikihi.custom.security.oauth2.domain.PrincipalDetails; import java.util.List; +import java.util.UUID; @RestController @RequestMapping("/api/v1/search") @RequiredArgsConstructor -public class SearchController { +public class SearchController implements SearchControllerSpec { - private final ElasticSearchService searchService; + private final SearchUseCase service; - // 상품 검색 + /// 상품 검색 @GetMapping public ApiResponse> searchProducts( @RequestParam("keyword") String keyword, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestParam(defaultValue = "0.001") float minScore + PageRequest pageRequest, + @AuthenticationPrincipal PrincipalDetails principalDetails ) { - List productList = searchService.searchProducts(keyword, page, size, minScore); + /// 유저가 없다면 null 저장 + UUID userId = principalDetails != null ? principalDetails.getId() : null; + + /// 서비스 호출 + List productList = service.searchProducts(keyword, pageRequest.getPage(), pageRequest.getSize(), userId); + + /// DTO 수정 List responses = ProductListResponse.from(productList); + /// 응답 return ApiResponse.ok(responses); } - // 상품 필터링 -// @GetMapping("/filter") -// public ApiResponse> filterProducts( -// @RequestParam("keyword") String keyword, -// @RequestBody SearchRequest req, -// PageRequest pageRequest -// ) { -// List products = searchService.filterProducts(keyword, req.manufacturer(), req.minPrice(), req.maxPrice(), pageRequest.getPage(), pageRequest.getSize()); -// return ApiResponse.ok(products); -// } + /// 나의 최근 검색어 조회 + @GetMapping("/my") + public ApiResponse> getMySearches( + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + + /// 서비스 호출 + List responses = service.getMySearches(principalDetails.getId()); + + /// 리턴 + return ApiResponse.ok(SearchListResponse.from(responses)); + + } + + /// 특정 검색어 삭제 + @DeleteMapping("/{id}") + public ApiResponse deleteSearch( + @PathVariable Long id, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + /// 서비스 호출 + service.deleteMySearchKeyword(id, principalDetails.getId()); + + /// 리턴 + return ApiResponse.deleted(); + } + + + /// 모든 검색어 삭제 + @DeleteMapping() + public ApiResponse deleteAllSearch( + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + /// 서비스 호출 + service.deleteAllKeywords(principalDetails.getId()); + + /// 리턴 + return ApiResponse.deleted(); + } + + /// 자동 저장 기능 조회 + @GetMapping("/auto") + public ApiResponse getMyAutoSearch( + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + + /// 서비스 호출 + boolean checked = service.checkSearch(principalDetails.getId()); + + String autoSearch = checked ? "자동 저장이 활성화되었습니다." : "자동 저장이 꺼져있습니다."; + + /// 리턴 + return ApiResponse.ok(autoSearch); + + } + + /// 자동 저장 기능 켜기 + @PutMapping("/auto/on") + public ApiResponse turnOnSearch( + @AuthenticationPrincipal PrincipalDetails principalDetails + ){ + + /// 서비스 호출 + service.turnOnMySearchKeyword(principalDetails.getId()); + + /// 리턴 + return ApiResponse.updated(); + + } + + /// 자동 저장 기능 끄기 + @PutMapping("/auto/off") + public ApiResponse turnOffSearch( + @AuthenticationPrincipal PrincipalDetails principalDetails + ){ + + /// 서비스 호출 + service.turnOffMySearchKeyword(principalDetails.getId()); + + /// 리턴 + return ApiResponse.updated(); + } + } diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/search/SearchListResponse.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/search/SearchListResponse.java new file mode 100644 index 0000000..07a96c4 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/search/SearchListResponse.java @@ -0,0 +1,27 @@ +package site.kikihi.custom.platform.adapter.in.web.dto.response.search; + +import lombok.Builder; +import site.kikihi.custom.platform.domain.search.Search; +import java.util.List; + +@Builder +public record SearchListResponse( + Long searchId, + String keyword +) { + + /// 정적 팩토리 메서드 + public static SearchListResponse from(Search search) { + return SearchListResponse.builder() + .searchId(search.getId()) + .keyword(search.getKeyword()) + .build(); + } + + /// 정적 팩토리 메서드 + public static List from(List searches) { + return searches.stream() + .map(SearchListResponse::from) + .toList(); + } +} diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/SearchControllerSpec.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/SearchControllerSpec.java new file mode 100644 index 0000000..6794a94 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/SearchControllerSpec.java @@ -0,0 +1,88 @@ +package site.kikihi.custom.platform.adapter.in.web.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import site.kikihi.custom.global.response.ApiResponse; +import site.kikihi.custom.global.response.page.PageRequest; +import site.kikihi.custom.platform.adapter.in.web.dto.response.product.ProductListResponse; +import site.kikihi.custom.platform.adapter.in.web.dto.response.search.SearchListResponse; +import site.kikihi.custom.security.oauth2.domain.PrincipalDetails; + +import java.util.List; + +@Tag(name = "검색 API", description = "검색을 위한 API 입니다.") +public interface SearchControllerSpec { + + @Operation( + summary = "검색 API", + description = "키워드를 바탕으로 조회합니다." + ) + ApiResponse> searchProducts( + @Parameter(example = "하우징") + @RequestParam("keyword") String keyword, + PageRequest pageRequest, + @AuthenticationPrincipal PrincipalDetails principalDetails + ); + + + @Operation( + summary = "나의 최근 검색어 목록 API", + description = "JWT를 바탕으로 나의 최근 검색어를 조회합니다." + ) + ApiResponse> getMySearches( + @AuthenticationPrincipal PrincipalDetails principalDetails + ); + + @Operation( + summary = "특정 검색어 삭제 API", + description = "JWT를 바탕으로 특정 검색어를 삭제합니다." + ) + ApiResponse deleteSearch( + @PathVariable Long id, + @AuthenticationPrincipal PrincipalDetails principalDetails + ); + + + + + @Operation( + summary = "모든 검색어 삭제 API", + description = "JWT를 바탕으로 모든 검색어를 삭제합니다." + ) + ApiResponse deleteAllSearch( + @AuthenticationPrincipal PrincipalDetails principalDetails + ); + + + @Operation( + summary = "검색어 자동저장 여부 API", + description = "JWT를 바탕으로 자동저장을 확인합니다." + ) + ApiResponse getMyAutoSearch( + @AuthenticationPrincipal PrincipalDetails principalDetails + ); + + + @Operation( + summary = "검색어 자동저장 기능 ON API", + description = "JWT를 바탕으로 자동저장을 킵니다." + ) + ApiResponse turnOnSearch( + @AuthenticationPrincipal PrincipalDetails principalDetails + ); + + + + + @Operation( + summary = "검색어 자동저장 기능 OFF API", + description = "JWT를 바탕으로 자동저장을 끕니다." + ) + ApiResponse turnOffSearch( + @AuthenticationPrincipal PrincipalDetails principalDetails + ); +} diff --git a/src/main/java/site/kikihi/custom/platform/adapter/out/SearchAdapter.java b/src/main/java/site/kikihi/custom/platform/adapter/out/SearchAdapter.java new file mode 100644 index 0000000..2e74b27 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/out/SearchAdapter.java @@ -0,0 +1,51 @@ +package site.kikihi.custom.platform.adapter.out; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import site.kikihi.custom.platform.adapter.out.jpa.search.SearchJpaEntity; +import site.kikihi.custom.platform.adapter.out.jpa.search.SearchJpaRepository; +import site.kikihi.custom.platform.application.out.search.SearchPort; +import site.kikihi.custom.platform.domain.search.Search; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class SearchAdapter implements SearchPort { + + private final SearchJpaRepository repository; + + @Override + public Search saveSearch(Search search) { + + var entity = SearchJpaEntity.from(search); + return repository.save(entity) + .toDomain(); + } + + @Override + public Optional getSearch(Long id) { + return repository.findById(id) + .map(SearchJpaEntity::toDomain); + } + + @Override + public List getSearches(UUID userId) { + return repository.findByUserIdOrderByCreatedAtDesc(userId).stream() + .map(SearchJpaEntity::toDomain) + .toList(); + } + + @Override + public void deleteSearch(Long searchId, UUID userId) { + repository.deleteByIdAndUserId(searchId, userId); + } + + + @Override + public void deleteALlSearch(UUID userId) { + repository.deleteAllByUserId(userId); + } +} diff --git a/src/main/java/site/kikihi/custom/platform/adapter/out/UserAdapter.java b/src/main/java/site/kikihi/custom/platform/adapter/out/UserAdapter.java index 56412e6..a1c4103 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/out/UserAdapter.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/out/UserAdapter.java @@ -1,5 +1,7 @@ package site.kikihi.custom.platform.adapter.out; +import org.springframework.transaction.annotation.Transactional; +import site.kikihi.custom.global.response.ErrorCode; import site.kikihi.custom.platform.adapter.out.jpa.user.UserJpaEntity; import site.kikihi.custom.platform.adapter.out.jpa.user.UserJpaRepository; import site.kikihi.custom.platform.application.out.user.UserPort; @@ -25,8 +27,15 @@ public User saveUser(User user) { } @Override - public User updateUser(User user) { - return null; + @Transactional + public void updateUser(User user) { + + /// 조회 + var entity = userJpaRepository.findById(user.getId()) + .orElseThrow(() -> new IllegalArgumentException(ErrorCode.USER_NOT_FOUND.getMessage())); + + /// 자동저장 여부 수정 + entity.updateSearch(user.isSearch()); } @Override diff --git a/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/search/SearchJpaEntity.java b/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/search/SearchJpaEntity.java new file mode 100644 index 0000000..16997f3 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/search/SearchJpaEntity.java @@ -0,0 +1,48 @@ +package site.kikihi.custom.platform.adapter.out.jpa.search; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import site.kikihi.custom.platform.adapter.out.jpa.BaseTimeEntity; +import site.kikihi.custom.platform.domain.search.Search; + +import java.util.UUID; + +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +public class SearchJpaEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private UUID userId; + + private String keyword; + + /// 정적 팩토리 메서드 + public static SearchJpaEntity from(Search search) { + return SearchJpaEntity.builder() + .userId(search.getUserId()) + .keyword(search.getKeyword()) + .build(); + } + + /// 도메인 + public Search toDomain() { + return Search.builder() + .id(id) + .keyword(keyword) + .userId(userId) + .build(); + + } +} diff --git a/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/search/SearchJpaRepository.java b/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/search/SearchJpaRepository.java new file mode 100644 index 0000000..368aeb1 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/search/SearchJpaRepository.java @@ -0,0 +1,15 @@ +package site.kikihi.custom.platform.adapter.out.jpa.search; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface SearchJpaRepository extends JpaRepository { + + List findByUserIdOrderByCreatedAtDesc(UUID userId); + + void deleteAllByUserId(UUID userId); + + void deleteByIdAndUserId(Long id, UUID userId); +} diff --git a/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/user/UserJpaEntity.java b/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/user/UserJpaEntity.java index 535744b..8a0a235 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/user/UserJpaEntity.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/out/jpa/user/UserJpaEntity.java @@ -41,6 +41,8 @@ public class UserJpaEntity extends BaseTimeEntity { @Embedded private AddressJpaEntity address; + private boolean isSearch; + @PrePersist public void generateUUID() { if (this.id == null) { @@ -59,6 +61,7 @@ public static UserJpaEntity from(User user) { .role(user.getRole()) .profileImage(user.getProfileImage()) .address(AddressJpaEntity.from(user.getAddress())) + .isSearch(user.isSearch()) .build(); } @@ -73,7 +76,12 @@ public User toDomain() { .role(role) .profileImage(profileImage) .address(address.toDomain()) + .isSearch(isSearch) .build(); } + /// 엔티티 수정용 + public void updateSearch(boolean isSearch) { + this.isSearch = isSearch; + } } diff --git a/src/main/java/site/kikihi/custom/platform/application/in/product/ProductSearchUseCase.java b/src/main/java/site/kikihi/custom/platform/application/in/product/ProductSearchUseCase.java deleted file mode 100644 index b55ca3a..0000000 --- a/src/main/java/site/kikihi/custom/platform/application/in/product/ProductSearchUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package site.kikihi.custom.platform.application.in.product; - -import site.kikihi.custom.platform.domain.product.Product; -import java.util.List; - -public interface ProductSearchUseCase { - List searchProducts(String keyword, int page, int size, float minScore); - List filterProducts(String keyword, String manufacturer, Double minPrice, Double maxPrice, int page, int size); -} diff --git a/src/main/java/site/kikihi/custom/platform/application/in/search/SearchUseCase.java b/src/main/java/site/kikihi/custom/platform/application/in/search/SearchUseCase.java new file mode 100644 index 0000000..ab6d812 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/application/in/search/SearchUseCase.java @@ -0,0 +1,40 @@ +package site.kikihi.custom.platform.application.in.search; + +import site.kikihi.custom.platform.domain.product.Product; +import site.kikihi.custom.platform.domain.search.Search; + +import java.util.List; +import java.util.UUID; + +/** + * 검색을 위한 유즈케이스입니다 + * - 키워드 기반 검색 + * - 나의 최근 검색어 조회 + * - 나의 최근 검색어 삭제 + * - 나의 최근 검색어 저장 끄기 + */ +public interface SearchUseCase { + + /// 검색 + List searchProducts(String keyword, int page, int size, UUID userId); + + /// 나의 검색에 목록 확인하기 + List getMySearches(UUID userId); + + /// 키워드 하나 삭제하기 + void deleteMySearchKeyword(Long searchId, UUID userId); + + /// 키워드 모두 삭제하기 + void deleteAllKeywords(UUID userId); + + /// 나의 최근 검색어 여부 + boolean checkSearch(UUID userId); + + /// 나의 최근 검색어 저장 끄기 + void turnOffMySearchKeyword(UUID userId); + + /// 나의 최근 검색어 저장 켜기 + void turnOnMySearchKeyword(UUID userId); + + +} diff --git a/src/main/java/site/kikihi/custom/platform/application/out/search/SearchPort.java b/src/main/java/site/kikihi/custom/platform/application/out/search/SearchPort.java new file mode 100644 index 0000000..6faee88 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/application/out/search/SearchPort.java @@ -0,0 +1,24 @@ +package site.kikihi.custom.platform.application.out.search; + +import site.kikihi.custom.platform.domain.search.Search; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface SearchPort { + + /// 최근 검색어 저장 + Search saveSearch(Search search); + + Optional getSearch(Long id); + + /// 유저의 최근 검색어 보기 + List getSearches(UUID userId); + + /// 최근 검색어 하나 삭제하기 + void deleteSearch(Long searchId, UUID userId); + + /// 최근 검색어 모두 삭제하기 + void deleteALlSearch(UUID userId); + +} diff --git a/src/main/java/site/kikihi/custom/platform/application/out/user/UserPort.java b/src/main/java/site/kikihi/custom/platform/application/out/user/UserPort.java index 7191910..9f39ba0 100644 --- a/src/main/java/site/kikihi/custom/platform/application/out/user/UserPort.java +++ b/src/main/java/site/kikihi/custom/platform/application/out/user/UserPort.java @@ -12,7 +12,7 @@ public interface UserPort { User saveUser(User user); // 수정하기 - User updateUser(User user); + void updateUser(User user); /// 조회하기 boolean checkExistingById(UUID userId); diff --git a/src/main/java/site/kikihi/custom/platform/application/service/ElasticSearchService.java b/src/main/java/site/kikihi/custom/platform/application/service/ElasticSearchService.java deleted file mode 100644 index 442d4a8..0000000 --- a/src/main/java/site/kikihi/custom/platform/application/service/ElasticSearchService.java +++ /dev/null @@ -1,108 +0,0 @@ -package site.kikihi.custom.platform.application.service; - -import site.kikihi.custom.platform.adapter.out.elasticSearch.ProductESDocument; -import site.kikihi.custom.platform.adapter.out.elasticSearch.ProductESRepository; -import site.kikihi.custom.platform.application.in.product.ProductSearchUseCase; -import site.kikihi.custom.platform.domain.product.Product; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.elasticsearch.client.elc.NativeQuery; -import org.springframework.data.elasticsearch.core.ElasticsearchOperations; -import org.springframework.data.elasticsearch.core.SearchHit; -import org.springframework.stereotype.Service; -import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; -import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery; -import co.elastic.clients.elasticsearch._types.query_dsl.Query; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class ElasticSearchService implements ProductSearchUseCase { - - private final ElasticsearchOperations elasticsearchOperations; - private final ProductESRepository productESRepository; - -// // 상품 저장 -// public Product saveProduct(Product product) { -// ProductESDocument doc = ProductESDocument.toESDocument(product); -// ProductESDocument saved = productESRepository.save(doc); -// return toDomain(saved); -// } - - // 키워드 검색 (name, description) - @Override - public List searchProducts(String keyword, int page, int size, float minScore) { - // match 쿼리 구성 - Query nameMatch = MatchQuery.of(m -> m.field("name").query(keyword))._toQuery(); - Query descMatch = MatchQuery.of(m -> m.field("description").query(keyword))._toQuery(); - - // bool 쿼리 - Query boolQuery = BoolQuery.of(b -> b - .should(nameMatch) - .should(descMatch) - .minimumShouldMatch("1") - )._toQuery(); - - // NativeQuery - NativeQuery query = NativeQuery.builder() - .withQuery(boolQuery) - .withPageable(PageRequest.of(page, size)) //page-> from으로 자동 변환(from=page * size) - .withMinScore(minScore) // <<-- 추가됨 - .build(); - - return elasticsearchOperations.search(query, ProductESDocument.class) - .stream() - .map(SearchHit::getContent) - .map(ProductESDocument::toDomain) - .collect(Collectors.toList()); - } - - - // 필터링 - @Override - public List filterProducts(String keyword, String manufacturer, Double minPrice, Double maxPrice, int page, int size) { - // 1. should 쿼리(키워드 검색) - List shouldQueries = new ArrayList<>(); - shouldQueries.add(MatchQuery.of(m -> m.field("productName").query(keyword))._toQuery()); - shouldQueries.add(MatchQuery.of(m -> m.field("description").query(keyword))._toQuery()); - - // 2. must 쿼리(제조사, 가격) - List mustQueries = new ArrayList<>(); - if (manufacturer != null && !manufacturer.isEmpty()) { - mustQueries.add(MatchQuery.of(m -> m.field("manufacturer").query(manufacturer))._toQuery()); - } -// if (minPrice != null || maxPrice != null) { -// RangeQuery rangeQuery = RangeQuery.of(r -> r -// .field("price") -// .gte(minPrice != null ? JsonData.of(minPrice) : null) -// .lte(maxPrice != null ? JsonData.of(maxPrice) : null) -// ); -// mustQueries.add(rangeQuery._toQuery()); -// } - - // 3. BoolQuery 조립 - Query boolQuery = BoolQuery.of(b -> b - .should(shouldQueries) - .must(mustQueries) - .minimumShouldMatch("1") - )._toQuery(); - - // 4. NativeQuery 생성 - NativeQuery nativeQuery = NativeQuery.builder() - .withQuery(boolQuery) - .withPageable(PageRequest.of(page, size)) - .build(); - - // 5. 검색 및 변환 - return elasticsearchOperations.search(nativeQuery, ProductESDocument.class) - .stream() - .map(SearchHit::getContent) - .map(ProductESDocument::toDomain) - .collect(Collectors.toList()); - } - - -} diff --git a/src/main/java/site/kikihi/custom/platform/application/service/SearchService.java b/src/main/java/site/kikihi/custom/platform/application/service/SearchService.java new file mode 100644 index 0000000..24a3dfc --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/application/service/SearchService.java @@ -0,0 +1,188 @@ +package site.kikihi.custom.platform.application.service; + +import site.kikihi.custom.global.response.ErrorCode; +import site.kikihi.custom.platform.adapter.out.elasticSearch.ProductESDocument; +import site.kikihi.custom.platform.adapter.out.elasticSearch.ProductESRepository; +import site.kikihi.custom.platform.application.in.search.SearchUseCase; +import site.kikihi.custom.platform.application.out.search.SearchPort; +import site.kikihi.custom.platform.application.out.user.UserPort; +import site.kikihi.custom.platform.domain.product.Product; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.stereotype.Service; +import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; +import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import site.kikihi.custom.platform.domain.search.Search; +import site.kikihi.custom.platform.domain.user.User; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class SearchService implements SearchUseCase { + + /// 의존성 + private final ElasticsearchOperations elasticsearchOperations; + private final ProductESRepository productESRepository; + private final SearchPort port; + + /// 외부 의존성 + private final UserPort userPort; + + /// 스태틱 + private final Float minScore = 0.001f; + + /// 키워드 검색 (name, description) + @Override + public List searchProducts(String keyword, int page, int size, UUID userId) { + // match 쿼리 구성 + Query nameMatch = MatchQuery.of(m -> m.field("name").query(keyword))._toQuery(); + Query descMatch = MatchQuery.of(m -> m.field("description").query(keyword))._toQuery(); + + // bool 쿼리 + Query boolQuery = BoolQuery.of(b -> b + .should(nameMatch) + .should(descMatch) + .minimumShouldMatch("1") + )._toQuery(); + + // NativeQuery + NativeQuery query = NativeQuery.builder() + .withQuery(boolQuery) + .withPageable(PageRequest.of(page, size)) //page-> from으로 자동 변환(from=page * size) + .withMinScore(minScore) // <<-- 추가됨 + .build(); + + /// 로그인 한 유저가 확인한다면, 최근 검색 기록 DB에 저장하기 + if (userId != null) { + + /// 유저 조회 + User user = getUser(userId); + + /// 자동저장이 ON인 유저만 저장한다. + if (user.isSearch()) { + + /// DB에 최신 검색어 저장하기 + Search search = Search.of(user.getId(), keyword); + port.saveSearch(search); + } + } + + return elasticsearchOperations.search(query, ProductESDocument.class) + .stream() + .map(SearchHit::getContent) + .map(ProductESDocument::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List getMySearches(UUID userId) { + + /// 유저 + User user = getUser(userId); + + /// 유저의 최근 검색어 조회하기 + return port.getSearches(user.getId()); + } + + /** + * 특정 키워드를 검색 기록에서 삭제합니다. + * @param searchId 삭제할 키워드 + * @param userId 유저 ID + */ + @Override + public void deleteMySearchKeyword(Long searchId, UUID userId) { + + /// 유저 예외 처리 + User user = getUser(userId); + + /// 검색 기록 예외 처리 + Search search = getSearch(searchId); + + /// 유저와 특정 키워드를 바탕으로 삭제합니다. + try { + port.deleteSearch(search.getId(), user.getId()); + } catch (Exception e) { + throw new IllegalArgumentException(ErrorCode.UNAUTHORIZED_DELETE_SEARCH.getMessage()); + } + + } + + /** + * 모든 키워드를 검색 기록에서 삭제합니다. + * @param userId 유저ID + */ + @Override + public void deleteAllKeywords(UUID userId) { + + /// 유저 예외 처리 + User user = getUser(userId); + + /// 전부 삭제하기 + port.deleteALlSearch(user.getId()); + } + + @Override + public boolean checkSearch(UUID userId) { + + /// 유저 예외 처리 + User user = getUser(userId); + + /// 유저의 여부 체크 + return user.isSearch(); + } + + + /** + * 검색 기록을 저장하지않도록 끕니다. + * @param userId 유저 ID + */ + @Override + public void turnOffMySearchKeyword(UUID userId) { + + /// 유저 + User user = getUser(userId); + + /// 켜져있을때만 끌 수있게 + if (user.isSearch()) { + user.turnOffSearch(); + userPort.updateUser(user); + } + } + + @Override + public void turnOnMySearchKeyword(UUID userId) { + /// 유저 + User user = getUser(userId); + + /// 꺼져있을때만 켤 수있게 + if (!user.isSearch()) { + user.turnOnSearch(); + userPort.updateUser(user); + } + + } + + /// 유저 조회 + private User getUser(UUID userId) { + + return userPort.loadUserById(userId) + .orElseThrow(() -> new NoSuchElementException(ErrorCode.USER_NOT_FOUND.getMessage())); + } + + + /// 검색 기록 조회 + private Search getSearch(Long searchId) { + + return port.getSearch(searchId) + .orElseThrow(() -> new NoSuchElementException(ErrorCode.SEARCH_NOT_FOUND.getMessage())); + } + +} diff --git a/src/main/java/site/kikihi/custom/platform/domain/search/Search.java b/src/main/java/site/kikihi/custom/platform/domain/search/Search.java new file mode 100644 index 0000000..98c488c --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/domain/search/Search.java @@ -0,0 +1,32 @@ +package site.kikihi.custom.platform.domain.search; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import site.kikihi.custom.platform.domain.BaseDomain; + +import java.util.UUID; + +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +public class Search extends BaseDomain { + + private Long id; + + private UUID userId; + + private String keyword; + + + /// 정적 팩토리 메서드 + public static Search of(UUID userId, String keyword) { + return Search.builder() + .userId(userId) + .keyword(keyword) + .build(); + } + +} diff --git a/src/main/java/site/kikihi/custom/platform/domain/user/User.java b/src/main/java/site/kikihi/custom/platform/domain/user/User.java index 3a17baa..6873967 100644 --- a/src/main/java/site/kikihi/custom/platform/domain/user/User.java +++ b/src/main/java/site/kikihi/custom/platform/domain/user/User.java @@ -24,7 +24,9 @@ public class User { private Role role; private String profileImage; private Address address; + private boolean isSearch = true; + /// 정적 팩토리 메서드 public static User of(OAuth2UserInfo userInfo) { return User.builder() .id(UUID.randomUUID()) @@ -36,6 +38,17 @@ public static User of(OAuth2UserInfo userInfo) { .profileImage(userInfo.getImageUrl()) .role(Role.USER) .address(Address.of()) + .isSearch(true) .build(); } + + /// 비즈니스 로직 + public void turnOnSearch() { + isSearch = true; + } + + public void turnOffSearch() { + isSearch = false; + } + } diff --git a/src/main/java/site/kikihi/custom/security/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/site/kikihi/custom/security/jwt/filter/JwtAuthenticationFilter.java index e6062ce..4d43a23 100644 --- a/src/main/java/site/kikihi/custom/security/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/site/kikihi/custom/security/jwt/filter/JwtAuthenticationFilter.java @@ -115,6 +115,11 @@ protected boolean shouldNotFilter(HttpServletRequest request) { return false; } + /// 검색은 회원/비회원 구분해야되기에 필터를 타도록 설정 + if (request.getRequestURI().startsWith("/api/v1/search")) { + return false; + } + /// null 인 것 해결 return requestMatcherHolder.getRequestMatchersByMinRole(null) .matches(request); diff --git a/src/main/java/site/kikihi/custom/security/jwt/filter/RequestMatcherHolder.java b/src/main/java/site/kikihi/custom/security/jwt/filter/RequestMatcherHolder.java index ff3c7b0..355b71d 100644 --- a/src/main/java/site/kikihi/custom/security/jwt/filter/RequestMatcherHolder.java +++ b/src/main/java/site/kikihi/custom/security/jwt/filter/RequestMatcherHolder.java @@ -7,6 +7,7 @@ import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.stereotype.Component; +import site.kikihi.custom.platform.domain.user.User; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -34,9 +35,15 @@ public class RequestMatcherHolder { // 상품 관련 new RequestInfo(GET, "/api/v1/products/**", null), - //추천 관련 + // 추천 관련 new RequestInfo(GET, "/api/v1/recommend/**", null), + // 검색 관련 + new RequestInfo(GET, "/api/v1/search", null), + new RequestInfo(GET, "/api/v1/search/**", Role.USER), + new RequestInfo(DELETE, "/api/v1/search/**", Role.USER), + new RequestInfo(PUT, "/api/v1/search/**", Role.USER), + // static resources new RequestInfo(GET, "/docs/**", null), new RequestInfo(GET, "/*.ico", null),