Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/dev-ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ name: 키키하이 dev CI-CD 파이프라인

on:
push:
branches: [ "develop","feat/product/KIKI-61-ElasticSearch"]
branches: [ "develop"]

Comment on lines 10 to 13
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

PR/특정 브랜치에서 CI가 동작하지 않을 위험 — pull_request 트리거 추가 및 배포 단계 가드 권장

push 트리거를 develop으로 한정하면, 기능 브랜치/PR에서는 CI가 아예 실행되지 않아 품질 게이트가 비어 있게 됩니다. PR 기반 검증을 위해 pull_request 트리거를 추가하고, 배포/푸시는 push(develop)에서만 실행되도록 가드해 주세요.

아래처럼 트리거와 배포 단계 조건을 분리하는 것을 제안드립니다.

 on:
-  push:
-    branches: [ "develop"]
+  push:
+    branches: [ "develop" ]
+  pull_request:
+    branches: [ "develop" ]
+  workflow_dispatch:

그리고 Docker 로그인/푸시 단계에 조건을 추가해 PR 이벤트에서는 스킵되도록 합니다.

-      - name: Docker 로그인
+      - name: Docker 로그인
+        if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
         uses: docker/login-action@v3
         with:
           username: ${{secrets.DOCKER_USERNAME}}
           password: ${{secrets.DOCKER_ACCESS_TOKEN}}

-      - name: Docker 이미지 Push
+      - name: Docker 이미지 Push
+        if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
         uses: docker/build-push-action@v4
         with:
           context: .
           dockerfile: Dockerfile
           push: true
           tags: ${{secrets.DOCKER_USERNAME}}/server:latest
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
on:
push:
branches: [ "develop","feat/product/KIKI-61-ElasticSearch"]
branches: [ "develop"]
on:
push:
branches: [ "develop" ]
pull_request:
branches: [ "develop" ]
workflow_dispatch:
Suggested change
on:
push:
branches: [ "develop","feat/product/KIKI-61-ElasticSearch"]
branches: [ "develop"]
- name: Docker 로그인
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
uses: docker/login-action@v3
with:
username: ${{secrets.DOCKER_USERNAME}}
password: ${{secrets.DOCKER_ACCESS_TOKEN}}
- name: Docker 이미지 Push
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
uses: docker/build-push-action@v4
with:
context: .
dockerfile: Dockerfile
push: true
tags: ${{secrets.DOCKER_USERNAME}}/server:latest
🤖 Prompt for AI Agents
.github/workflows/dev-ci-cd.yml around lines 10 to 13: currently only a push
trigger for develop is declared which means PRs/feature branches won't run CI;
add a pull_request trigger (e.g., for branches: [develop]) so PR events run the
workflow, and then guard deployment/push steps by conditioning them to run only
on push to refs/heads/develop (e.g., job or step if: github.event_name == 'push'
&& github.ref == 'refs/heads/develop') and add an additional condition to skip
Docker login/push when github.event_name == 'pull_request' so those steps only
execute on actual develop pushes.

jobs:
#1. 개발 서버 CI, Build 용
Expand Down
16 changes: 10 additions & 6 deletions src/main/java/site/kikihi/custom/global/response/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
/**
* 애플리케이션 전역에서 사용하는 에러 코드 Enum입니다.
* 각 에러는 고유 코드, HTTP 상태, 메시지를 포함합니다.
*
* <p>
* 400 : 잘못된 요청 에러
* 401 : 로그인 관련 에러
* 403 : 권한 부족 관련 에러
Expand All @@ -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, "이미 검색어 저장 기능이 꺼져있습니다"),


// ========================
Expand All @@ -48,7 +50,6 @@ public enum ErrorCode {
TOKEN_NOT_FOUND_COOKIE(401_011, HttpStatus.UNAUTHORIZED, "쿠키에 리프레시 토큰이 존재하지 않습니다."),



// ========================
// 403 Forbidden
// ========================
Expand All @@ -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, "검색기록을 삭제할 권한이 없습니다."),


// ========================
Expand All @@ -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, "서버 내부 오류입니다."),
;

// 기타 공통

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ private User createDev() {
.provider(Provider.KAKAO) // 테스트용 값 (Enum)
.role(Role.ADMIN) // 관리자 권한 부여
.address(Address.of())
.isSearch(true)
.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<ProductListResponse>> 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<Product> productList = searchService.searchProducts(keyword, page, size, minScore);
/// 유저가 없다면 null 저장
UUID userId = principalDetails != null ? principalDetails.getId() : null;

/// 서비스 호출
List<Product> productList = service.searchProducts(keyword, pageRequest.getPage(), pageRequest.getSize(), userId);

/// DTO 수정
List<ProductListResponse> responses = ProductListResponse.from(productList);

/// 응답
return ApiResponse.ok(responses);
}

// 상품 필터링
// @GetMapping("/filter")
// public ApiResponse<List<Product>> filterProducts(
// @RequestParam("keyword") String keyword,
// @RequestBody SearchRequest req,
// PageRequest pageRequest
// ) {
// List<Product> products = searchService.filterProducts(keyword, req.manufacturer(), req.minPrice(), req.maxPrice(), pageRequest.getPage(), pageRequest.getSize());
// return ApiResponse.ok(products);
// }
/// 나의 최근 검색어 조회
@GetMapping("/my")
public ApiResponse<List<SearchListResponse>> getMySearches(
@AuthenticationPrincipal PrincipalDetails principalDetails
) {

/// 서비스 호출
List<Search> responses = service.getMySearches(principalDetails.getId());

/// 리턴
return ApiResponse.ok(SearchListResponse.from(responses));

}

/// 특정 검색어 삭제
@DeleteMapping("/{id}")
public ApiResponse<Void> deleteSearch(
@PathVariable Long id,
@AuthenticationPrincipal PrincipalDetails principalDetails
) {
/// 서비스 호출
service.deleteMySearchKeyword(id, principalDetails.getId());

/// 리턴
return ApiResponse.deleted();
}


/// 모든 검색어 삭제
@DeleteMapping()
public ApiResponse<Void> deleteAllSearch(
@AuthenticationPrincipal PrincipalDetails principalDetails
) {
/// 서비스 호출
service.deleteAllKeywords(principalDetails.getId());

/// 리턴
return ApiResponse.deleted();
}

/// 자동 저장 기능 조회
@GetMapping("/auto")
public ApiResponse<String> getMyAutoSearch(
@AuthenticationPrincipal PrincipalDetails principalDetails
) {

/// 서비스 호출
boolean checked = service.checkSearch(principalDetails.getId());

String autoSearch = checked ? "자동 저장이 활성화되었습니다." : "자동 저장이 꺼져있습니다.";

/// 리턴
return ApiResponse.ok(autoSearch);

}

/// 자동 저장 기능 켜기
@PutMapping("/auto/on")
public ApiResponse<Void> turnOnSearch(
@AuthenticationPrincipal PrincipalDetails principalDetails
){

/// 서비스 호출
service.turnOnMySearchKeyword(principalDetails.getId());

/// 리턴
return ApiResponse.updated();

}

/// 자동 저장 기능 끄기
@PutMapping("/auto/off")
public ApiResponse<Void> turnOffSearch(
@AuthenticationPrincipal PrincipalDetails principalDetails
){

/// 서비스 호출
service.turnOffMySearchKeyword(principalDetails.getId());

/// 리턴
return ApiResponse.updated();
}

}
Original file line number Diff line number Diff line change
@@ -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<SearchListResponse> from(List<Search> searches) {
return searches.stream()
.map(SearchListResponse::from)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -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<List<ProductListResponse>> searchProducts(
@Parameter(example = "하우징")
@RequestParam("keyword") String keyword,
PageRequest pageRequest,
@AuthenticationPrincipal PrincipalDetails principalDetails
);


@Operation(
summary = "나의 최근 검색어 목록 API",
description = "JWT를 바탕으로 나의 최근 검색어를 조회합니다."
)
ApiResponse<List<SearchListResponse>> getMySearches(
@AuthenticationPrincipal PrincipalDetails principalDetails
);

@Operation(
summary = "특정 검색어 삭제 API",
description = "JWT를 바탕으로 특정 검색어를 삭제합니다."
)
ApiResponse<Void> deleteSearch(
@PathVariable Long id,
@AuthenticationPrincipal PrincipalDetails principalDetails
);




@Operation(
summary = "모든 검색어 삭제 API",
description = "JWT를 바탕으로 모든 검색어를 삭제합니다."
)
ApiResponse<Void> deleteAllSearch(
@AuthenticationPrincipal PrincipalDetails principalDetails
);


@Operation(
summary = "검색어 자동저장 여부 API",
description = "JWT를 바탕으로 자동저장을 확인합니다."
)
ApiResponse<String> getMyAutoSearch(
@AuthenticationPrincipal PrincipalDetails principalDetails
);


@Operation(
summary = "검색어 자동저장 기능 ON API",
description = "JWT를 바탕으로 자동저장을 킵니다."
)
ApiResponse<Void> turnOnSearch(
@AuthenticationPrincipal PrincipalDetails principalDetails
);
Comment on lines +71 to +76
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

오타 수정: “킵니다” → “켭니다”

사용자 노출 문구이므로 맞춤법을 맞춰 주세요.

     @Operation(
             summary = "검색어 자동저장 기능 ON API",
-            description = "JWT를 바탕으로 자동저장을 킵니다."
+            description = "JWT를 바탕으로 자동저장을 켭니다."
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
summary = "검색어 자동저장 기능 ON API",
description = "JWT를 바탕으로 자동저장을 킵니다."
)
ApiResponse<Void> turnOnSearch(
@AuthenticationPrincipal PrincipalDetails principalDetails
);
@Operation(
summary = "검색어 자동저장 기능 ON API",
description = "JWT를 바탕으로 자동저장을 켭니다."
)
🤖 Prompt for AI Agents
In
src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/SearchControllerSpec.java
around lines 71 to 76, the Korean description contains a typo: change "자동저장을
킵니다." to the correct "자동저장을 켭니다." Update the string literal in the description
annotation accordingly, preserving surrounding spacing and formatting.





@Operation(
summary = "검색어 자동저장 기능 OFF API",
description = "JWT를 바탕으로 자동저장을 끕니다."
)
ApiResponse<Void> turnOffSearch(
@AuthenticationPrincipal PrincipalDetails principalDetails
);
}
Original file line number Diff line number Diff line change
@@ -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<Search> getSearch(Long id) {
return repository.findById(id)
.map(SearchJpaEntity::toDomain);
}

@Override
public List<Search> getSearches(UUID userId) {
return repository.findByUserIdOrderByCreatedAtDesc(userId).stream()
.map(SearchJpaEntity::toDomain)
.toList();
}

@Override
public void deleteSearch(Long searchId, UUID userId) {
repository.deleteByIdAndUserId(searchId, userId);
}
Comment on lines +41 to +44
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

권한 검증이 실질적으로 동작하지 않을 수 있습니다 — 삭제 건수 확인 로직 추가 제안

deleteByIdAndUserId는 보통 예외를 던지지 않고 0건 삭제로 끝납니다. 현재 어댑터는 반환값을 확인하지 않아, 서비스 단에서 항상 성공처럼 보일 수 있습니다(무단 삭제/타 사용자 리소스에 대한 요청을 구분 못 함).

아래처럼 삭제 건수를 확인하고 0건이면 EmptyResultDataAccessException을 던지도록 바꾸면, 서비스가 이를 UNAUTHORIZED_DELETE_SEARCH로 매핑할 수 있습니다.

@@
+import org.springframework.dao.EmptyResultDataAccessException;
@@
     @Override
     public void deleteSearch(Long searchId, UUID userId) {
-        repository.deleteByIdAndUserId(searchId, userId);
+        long deleted = repository.deleteByIdAndUserId(searchId, userId);
+        if (deleted == 0) {
+            throw new EmptyResultDataAccessException("No search found for given id and userId", 1);
+        }
     }

참고: 위 변경을 위해 SearchJpaRepository#deleteByIdAndUserId의 시그니처를 voidlong으로 조정이 필요합니다. 원하시면 레포지토리/포트/서비스까지 일괄 변경 PR 패치도 드릴게요.



@Override
public void deleteALlSearch(UUID userId) {
repository.deleteAllByUserId(userId);
}
}
Loading
Loading