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),