-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/50/ingredient elastic search #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: release
Are you sure you want to change the base?
Changes from all commits
584bce8
e995197
57af20e
3991229
2d1aca5
f037cc2
69a7249
e7d0573
031c70a
38a743d
09caa77
224e149
705eae0
7c4c5d5
244a7de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| FROM elasticsearch:8.18.6 | ||
| RUN bin/elasticsearch-plugin install analysis-nori | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,18 +3,26 @@ | |
| import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration; | ||
| import org.springframework.boot.SpringApplication; | ||
| import org.springframework.boot.autoconfigure.SpringBootApplication; | ||
| import org.springframework.boot.autoconfigure.data.elasticsearch.ReactiveElasticsearchRepositoriesAutoConfiguration; | ||
| import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration; | ||
| import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories; | ||
| import org.springframework.data.jpa.repository.config.EnableJpaAuditing; | ||
| import org.springframework.data.jpa.repository.config.EnableJpaRepositories; | ||
| import org.springframework.retry.annotation.EnableRetry; | ||
|
|
||
| @SpringBootApplication( | ||
| exclude = { | ||
| RedisRepositoriesAutoConfiguration.class, | ||
| S3AutoConfiguration.class | ||
| S3AutoConfiguration.class, | ||
| ReactiveElasticsearchRepositoriesAutoConfiguration.class | ||
| } | ||
| ) | ||
| @EnableJpaAuditing | ||
| @EnableRetry | ||
| @EnableJpaRepositories( | ||
| basePackages = "novaminds.gradproj.domain" | ||
| ) | ||
| @EnableElasticsearchRepositories(basePackages = "novaminds.gradproj.global.search.repository") | ||
|
Comment on lines
+22
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π‘ Verification agent π§© Analysis chainGate Elasticsearch repositories behind the If ES is unavailable (CI, prod without ES), repository/bootstrapping beans may cause startup or runtime failures. Prefer enabling ES repos only when Proposed approach (move to a dedicated config): @Configuration
@Profile("es")
@EnableElasticsearchRepositories(basePackages = "novaminds.gradproj.global.search.repository")
class EsRepositoriesConfig {}Verification script to check ES beans are profile-scoped: π Script executed: #!/bin/bash
rg -n -C2 '@EnableElasticsearchRepositories|EsIndexBootstrap|IngredientIndexingService|SearchQueryService' src/main/java
rg -n '@Profile\("es"\)' -C2 src/main/java/novaminds/gradproj/global/searchLength of output: 3715 Gate Elasticsearch behind the "es" profile (make ES repos and ES-only beans conditional). @EnableElasticsearchRepositories is enabled unconditionally in src/main/java/novaminds/gradproj/GradprojApplication.java (line 25). ES-related beans are unprofiled and will attempt ES wiring at startup: src/main/java/novaminds/gradproj/global/search/EsIndexBootstrap.java (class ~line 11), SearchQueryService.java (line 18), IngredientIndexingService.java (line 18), SearchController.java (lines 15β16). Action: move @EnableElasticsearchRepositories into a @configuration annotated with @Profile("es") and make ES bootstrap/services conditional (either annotate them with @Profile("es") or use @ConditionalOnBean(ElasticsearchOperations.class) / @ConditionalOnProperty). Example: @Configuration
@Profile("es")
@EnableElasticsearchRepositories(basePackages = "novaminds.gradproj.global.search.repository")
class EsRepositoriesConfig {}π€ Prompt for AI Agents |
||
| public class GradprojApplication { | ||
|
|
||
| public static void main(String[] args) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package novaminds.gradproj.global.search; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.boot.context.event.ApplicationReadyEvent; | ||
| import org.springframework.context.event.EventListener; | ||
| import org.springframework.data.elasticsearch.core.ElasticsearchOperations; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class EsIndexBootstrap { | ||
| private final ElasticsearchOperations operations; | ||
|
|
||
| @EventListener(ApplicationReadyEvent.class) | ||
| public void init() { | ||
| var idx = operations.indexOps(IngredientDocument.class); | ||
| if (!idx.exists()) { | ||
| idx.create(); // @Setting(nori-setting.json) λ°μ | ||
| idx.putMapping(idx.createMapping(IngredientDocument.class)); // @MultiField λ§€ν λ°μ | ||
| } | ||
|
Comment on lines
+17
to
+20
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. νμ¬ κ΅¬νμ μ ν리μΌμ΄μ
μμ μ μΈλ±μ€κ° μ‘΄μ¬νμ§ μμ λλ§ μμ±νλλ‘ λμ΄ μμ΅λλ€. λ§μ½ if (idx.exists()) {
idx.delete();
}
idx.create(); // @Setting(nori-setting.json) λ°μ
idx.putMapping(idx.createMapping(IngredientDocument.class)); // @MultiField λ§€ν λ°μ |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package novaminds.gradproj.global.search; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
| import org.springframework.data.annotation.Id; | ||
| import org.springframework.data.elasticsearch.annotations.*; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| @Builder | ||
| @Document(indexName = "ingredients") // ingredients λΌλ μ΄λ¦μ μΈλ±μ€(μλμ₯)μ μ μ₯ | ||
| @Setting(settingPath = "es-setting/nori-setting.json") // νκΈ ννμ λΆμκΈ° μ€μ μΆκ° | ||
| public class IngredientDocument { | ||
|
|
||
| @Id | ||
| private Long id; // μλ³Έ DBμ Ingredient ID | ||
|
|
||
| // λͺ¨λ κ²μμ μ± μμ§ λ¨ νλμ λ©μΈ νλ | ||
| @Field(type = FieldType.Text, analyzer = "korean_unified_analyzer") | ||
| private String ingredientName; | ||
|
|
||
| // μ ν μΌμΉ λ° μ λ ¬μ μν ν€μλ νλ | ||
| @Field(type = FieldType.Keyword) | ||
| private String ingredientNameKw; | ||
|
|
||
| @Field(type = FieldType.Keyword, name = "category_name") // μΉ΄ν κ³ λ¦¬ μ΄λ¦μ μ νν μΌμΉν΄μΌ νλ―λ‘ Keyword νμ μ¬μ© | ||
| private String categoryName; | ||
|
|
||
| @Field(type = FieldType.Keyword, name = "image_url", index = false) // μ΄λ―Έμ§ URLμ κ²μ λμμ΄ μλλ―λ‘ index=false | ||
| private String imageUrl; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package novaminds.gradproj.global.search.converter; | ||
|
|
||
| import novaminds.gradproj.global.search.IngredientDocument; | ||
| import novaminds.gradproj.global.search.web.dto.SearchResponseDTO; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public class SearchConverter { | ||
|
|
||
|
Comment on lines
+8
to
+9
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. μ΄ ν΄λμ€λ μ μ μ νΈλ¦¬ν° λ©μλλ§ ν¬ν¨νκ³ μμ΅λλ€. μ΄λ° μ νΈλ¦¬ν° ν΄λμ€λ μΈμ€ν΄μ€νλκ±°λ μμλ νμκ° μμΌλ―λ‘, public final class SearchConverter {
private SearchConverter() {
// μ νΈλ¦¬ν° ν΄λμ€λ μΈμ€ν΄μ€νν μ μμ΅λλ€.
} |
||
| public static SearchResponseDTO.IngredientSearchResult toIngredientSearchResultDTO(IngredientDocument document) { | ||
| return SearchResponseDTO.IngredientSearchResult.builder() | ||
| .id(document.getId()) | ||
| .name(document.getIngredientName()) | ||
| .category(document.getCategoryName()) | ||
| .imageUrl(document.getImageUrl()) | ||
| .build(); | ||
| } | ||
|
|
||
| public static SearchResponseDTO.IngredientSearchList toIngredientSearchListDTO(List<IngredientDocument> documents) { | ||
| List<SearchResponseDTO.IngredientSearchResult> results = documents.stream() | ||
| .map(SearchConverter::toIngredientSearchResultDTO) | ||
| .toList(); | ||
|
|
||
| return SearchResponseDTO.IngredientSearchList.builder() | ||
| .results(results) | ||
| .build(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package novaminds.gradproj.global.search.repository; | ||
|
|
||
| import novaminds.gradproj.global.search.IngredientDocument; | ||
| import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; | ||
|
|
||
| public interface IngredientSearchRepository extends ElasticsearchRepository<IngredientDocument, Long> { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package novaminds.gradproj.global.search.service; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import novaminds.gradproj.domain.ingredient.entity.Ingredient; | ||
| import novaminds.gradproj.domain.ingredient.repository.IngredientRepository; | ||
| import novaminds.gradproj.global.search.IngredientDocument; | ||
| import novaminds.gradproj.global.search.repository.IngredientSearchRepository; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class IngredientIndexingService { | ||
|
|
||
| private final IngredientRepository ingredientRepository; // DBμμ λ°μ΄ν°λ₯Ό μ½μ΄μ¬ JpaRepository | ||
| private final IngredientSearchRepository ingredientSearchRepository; // Elasticsearchμ λ°μ΄ν°λ₯Ό μ μ₯ν Repository | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public void indexIngredients() { | ||
| log.info(">>>> Ingredient λ°μ΄ν° μΈλ±μ± μμ..."); | ||
|
|
||
| // 1. DBμμ λͺ¨λ μ¬λ£ λ°μ΄ν°λ₯Ό μ‘°ν | ||
| List<Ingredient> allIngredients = ingredientRepository.findAll(); | ||
| if (allIngredients.isEmpty()) { | ||
| log.info(">>>> μΈλ±μ±ν μ¬λ£ λ°μ΄ν°κ° μμ΅λλ€."); | ||
| return; | ||
| } | ||
|
|
||
| // 2. Ingredient μν°ν°λ₯Ό IngredientDocumentλ‘ λ³ν | ||
| List<IngredientDocument> ingredientDocuments = allIngredients.stream() | ||
| .map(ingredient -> IngredientDocument.builder() | ||
| .id(ingredient.getId()) | ||
| .ingredientName(ingredient.getIngredientName()) | ||
| .ingredientNameKw(ingredient.getIngredientName()) | ||
| .categoryName(ingredient.getIngredientCategory().getIngredientCategoryName()) | ||
| .imageUrl(ingredient.getImageUrl()) | ||
| .build()) | ||
| .toList(); | ||
|
|
||
| // 3. Elasticsearchμ λ³νλ λ°μ΄ν°λ₯Ό μ μ₯(μΈλ±μ±) | ||
| ingredientSearchRepository.saveAll(ingredientDocuments); | ||
|
|
||
| log.info(">>>> Ingredient λ°μ΄ν° μΈλ±μ± μλ£ ({}κ°)", ingredientDocuments.size()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package novaminds.gradproj.global.search.service; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import novaminds.gradproj.global.search.IngredientDocument; | ||
| import novaminds.gradproj.global.search.converter.SearchConverter; | ||
| import novaminds.gradproj.global.search.web.dto.SearchResponseDTO; | ||
| 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.data.elasticsearch.core.SearchHits; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class SearchQueryService { | ||
|
|
||
| private final ElasticsearchOperations operations; | ||
|
|
||
| public SearchResponseDTO.IngredientSearchList searchIngredients(String keyword, int page, int size) { | ||
| if (keyword == null || keyword.isBlank()) { | ||
| return SearchConverter.toIngredientSearchListDTO(List.of()); | ||
| } | ||
|
|
||
| NativeQuery query = NativeQuery.builder() | ||
| .withQuery(q -> q | ||
| .multiMatch(mm -> mm | ||
| .query(keyword) | ||
| .fields("ingredientName", "ingredientNameKw") | ||
| .fuzziness("AUTO") | ||
| ) | ||
| ) | ||
| .withMinScore(2.0f) | ||
| .withPageable(PageRequest.of(page, size)) | ||
| .build(); | ||
|
|
||
| SearchHits<IngredientDocument> hits = operations.search(query, IngredientDocument.class); | ||
| List<IngredientDocument> docs = hits.getSearchHits().stream() | ||
| .map(SearchHit::getContent).toList(); | ||
|
|
||
| return SearchConverter.toIngredientSearchListDTO(docs); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,33 @@ | ||||||||||||||||||||||||||||||||||||||||||
| package novaminds.gradproj.global.search.web.controller; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||||
| import novaminds.gradproj.apiPayload.ApiResponse; | ||||||||||||||||||||||||||||||||||||||||||
| import novaminds.gradproj.global.search.service.IngredientIndexingService; | ||||||||||||||||||||||||||||||||||||||||||
| import novaminds.gradproj.global.search.service.SearchQueryService; | ||||||||||||||||||||||||||||||||||||||||||
| import novaminds.gradproj.global.search.web.dto.SearchResponseDTO; | ||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.bind.annotation.*; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| @RestController | ||||||||||||||||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||||||||||||||||
| @RequestMapping("/api/search") | ||||||||||||||||||||||||||||||||||||||||||
| public class SearchController { | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| private final IngredientIndexingService ingredientIndexingService; | ||||||||||||||||||||||||||||||||||||||||||
| private final SearchQueryService searchQueryService; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| @PostMapping("/admin/reindex") | ||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||||||||
| public ApiResponse<String> reindexIngredients() { | ||||||||||||||||||||||||||||||||||||||||||
| ingredientIndexingService.indexIngredients(); | ||||||||||||||||||||||||||||||||||||||||||
| return ApiResponse.onSuccess("μΈλ±μ±μ΄ μ±κ³΅μ μΌλ‘ μλ£λμμ΅λλ€."); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+18
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Restrict reindex endpoint to admin role The reindex endpoint is mapped to Useful? React with πΒ / π. |
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+18
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Protect the reindex endpoint (authorization + environment gating). Unauthenticated/unauthorized reindexing is a security and operational risk. Restrict to admins and optionally expose only on local/dev. +import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.context.annotation.Profile;
...
- @PostMapping("/admin/reindex")
+ @PostMapping("/admin/reindex")
+ @PreAuthorize("hasRole('ADMIN')")
+ @Profile({"local","dev"})
public ApiResponse<String> reindexIngredients() {
ingredientIndexingService.indexIngredients();
return ApiResponse.onSuccess("μΈλ±μ±μ΄ μ±κ³΅μ μΌλ‘ μλ£λμμ΅λλ€.");
}If Spring Security isnβt configured, at minimum add a shared secret header check or remove this endpoint from
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| @GetMapping("/ingredients") | ||||||||||||||||||||||||||||||||||||||||||
| public ApiResponse<SearchResponseDTO.IngredientSearchList> searchIngredients( | ||||||||||||||||||||||||||||||||||||||||||
| @RequestParam("keyword") String keyword, | ||||||||||||||||||||||||||||||||||||||||||
| @RequestParam(defaultValue = "0") int page, | ||||||||||||||||||||||||||||||||||||||||||
| @RequestParam(defaultValue = "20") int size | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+27
to
+28
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. νμ΄μ§λ€μ΄μ
νλΌλ―Έν°μΈ
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||
| SearchResponseDTO.IngredientSearchList searchResults = searchQueryService.searchIngredients(keyword, page, size); | ||||||||||||||||||||||||||||||||||||||||||
| return ApiResponse.onSuccess(searchResults); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+24
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π οΈ Refactor suggestion Validate and clamp pagination parameters. Unbounded - public ApiResponse<SearchResponseDTO.IngredientSearchList> searchIngredients(
- @RequestParam("keyword") String keyword,
- @RequestParam(defaultValue = "0") int page,
- @RequestParam(defaultValue = "20") int size
- ) {
- SearchResponseDTO.IngredientSearchList searchResults = searchQueryService.searchIngredients(keyword, page, size);
+ public ApiResponse<SearchResponseDTO.IngredientSearchList> searchIngredients(
+ @RequestParam("keyword") String keyword,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size
+ ) {
+ page = Math.max(0, page);
+ size = Math.max(1, Math.min(100, size));
+ var searchResults = searchQueryService.searchIngredients(keyword, page, size);
return ApiResponse.onSuccess(searchResults);
}π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package novaminds.gradproj.global.search.web.dto; | ||
|
|
||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public class SearchResponseDTO { | ||
|
|
||
| @Getter | ||
| @Builder | ||
| public static class IngredientSearchList { | ||
| private List<IngredientSearchResult> results; | ||
| } | ||
|
|
||
| @Getter | ||
| @Builder | ||
| public static class IngredientSearchResult { | ||
| private Long id; | ||
| private String name; | ||
| private String category; | ||
| private String imageUrl; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| spring: | ||
| elasticsearch: | ||
| uris: http://localhost:9200 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,4 +10,5 @@ spring: | |
| - jwt | ||
| - s3 | ||
| - redis | ||
| - redis | ||
| - es | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| { | ||
| "index": { | ||
| "max_ngram_diff": 20 | ||
| }, | ||
| "analysis": { | ||
| "filter": { | ||
| "edge_ngram_filter": { "type": "edge_ngram", "min_gram": 1, "max_gram": 20 } | ||
| }, | ||
| "analyzer": { | ||
| "korean_unified_analyzer": { | ||
| "type": "custom", | ||
| "tokenizer": "nori_tokenizer", | ||
| "filter": [ "lowercase", "nori_readingform", "edge_ngram_filter" ] | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make plugin install non-interactive to prevent CI hangs.
Without
--batch,elasticsearch-plugin installcan block waiting for confirmation duringdocker build.Apply this diff:
π Committable suggestion
π€ Prompt for AI Agents