Skip to content
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

[feat] 종목 스크롤 검색 #396

Merged
merged 5 commits into from
Jul 13, 2024
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
/src/main/resources/application-secret.yml
/out/
/src/main/resources/secret/
/src/main/generated/
25 changes: 25 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ dependencies {
// Csv Reader
implementation 'org.apache.commons:commons-csv:1.11.0'

//QueryDsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
// java.lang.NoClassDefFoundError 대응을 위해 추가
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
}

tasks.named('test') {
Expand Down Expand Up @@ -144,3 +150,22 @@ bootJar {
archiveFileName = "fineAnts_app.jar"
copyPrivate
}

// QueryDSL configuration start
def generated = "src/main/generated"

//Querydsl Q Class 생성 위치 지정
tasks.withType(JavaCompile).configureEach {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}

//java source set 에 Querydsl Q Class 위치 추가
sourceSets {
main.java.srcDirs += [generated]
}

//gradle clean 시, Q Class 디렉토리까지 삭제하도록 설정
clean {
delete file(generated)
}
// QueryDSL configuration end
18 changes: 18 additions & 0 deletions src/docs/asciidoc/api/stock.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ include::{snippets}/stock-search/request-fields.adoc[]
include::{snippets}/stock-search/http-response.adoc[]
include::{snippets}/stock-search/response-fields.adoc[]

[[stock-scroll-search]]
=== 종목 스크롤 검색

- 추가적인 종목 검색시 **종목 리스트의 마지막 종목의 tickerSymbol을 tickerSymbol 쿼리 파라미터에 전달**합니다.
- size 쿼리 파라미터의 기본값은 10입니다.
- tickerSymobl, size, keyword는 모두 선택 옵션입니다.
- keyword를 이용하여 종목코드(StockCode), 티커심볼(TickerSymbol), 회사명, 회사 영문명이 포함된 종목을 검색합니다.

==== HTTP Request

include::{snippets}/stock-scroll-search/http-request.adoc[]
include::{snippets}/stock-scroll-search/query-parameters.adoc[]

==== HTTP Response

include::{snippets}/stock-scroll-search/http-response.adoc[]
include::{snippets}/stock-scroll-search/response-fields.adoc[]

[[stock-refresh]]
=== 종목 최신화

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import codesquad.fineants.domain.stock.domain.dto.request.StockSearchRequest;
Expand All @@ -33,6 +34,15 @@ public ApiResponse<List<StockSearchItem>> search(@RequestBody final StockSearchR
return ApiResponse.success(StockSuccessCode.OK_SEARCH_STOCKS, stockService.search(request));
}

@GetMapping("/search")
@PermitAll
public ApiResponse<List<StockSearchItem>> search(
@RequestParam(name = "tickerSymbol", required = false) String tickerSymbol,
@RequestParam(name = "size", required = false, defaultValue = "10") int size,
@RequestParam(name = "keyword", required = false) String keyword) {
return ApiResponse.success(StockSuccessCode.OK_SEARCH_STOCKS, stockService.search(tickerSymbol, size, keyword));
}

@PostMapping("/refresh")
@Secured(value = {"ROLE_MANAGER", "ROLE_ADMIN"})
public ApiResponse<StockRefreshResponse> refreshStocks() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ public static StockInfo from(Stock stock) {
.market(stock.getMarket().name())
.build();
}

public Stock toEntity() {
return Stock.of(
tickerSymbol,
companyName,
companyNameEng,
stockCode,
null,
Market.ofMarket(market)
);
}
}

@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import codesquad.fineants.domain.stock.domain.entity.Stock;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class StockSearchItem {
private String stockCode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package codesquad.fineants.domain.stock.repository;

import static codesquad.fineants.domain.stock.domain.entity.QStock.*;

import java.util.List;
import java.util.Objects;

import javax.annotation.Nullable;

import org.springframework.stereotype.Repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;

import codesquad.fineants.domain.stock.domain.entity.Stock;
import io.jsonwebtoken.lang.Strings;
import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class StockQueryRepository {
private final JPAQueryFactory jpaQueryFactory;

public List<Stock> getSliceOfStock(@Nullable String tickerSymbol, int size, @Nullable String keyword) {
return jpaQueryFactory.selectFrom(stock)
.where(ltStockTickerSymbol(tickerSymbol), search(keyword))
.orderBy(stock.tickerSymbol.desc())
.limit(size)
.fetch();
}

private BooleanExpression ltStockTickerSymbol(@Nullable String tickerSymbol) {
return tickerSymbol == null ? null : stock.tickerSymbol.lt(tickerSymbol);
}

private BooleanExpression search(String keyword) {
if (keyword == null) {
return null;
}
return Objects.requireNonNull(containsStockCode(keyword))
.or(containsTickerSymbol(keyword))
.or(containsCompanyName(keyword))
.or(containsCompanyNameEng(keyword));
}

private BooleanExpression containsStockCode(@Nullable String keyword) {
return Strings.hasText(keyword) ? stock.stockCode.contains(keyword) : null;
}

private BooleanExpression containsTickerSymbol(@Nullable String keyword) {
return Strings.hasText(keyword) ? stock.tickerSymbol.contains(keyword) : null;
}

private BooleanExpression containsCompanyName(@Nullable String keyword) {
return Strings.hasText(keyword) ? stock.companyName.contains(keyword) : null;
}

private BooleanExpression containsCompanyNameEng(@Nullable String keyword) {
return Strings.hasText(keyword) ? stock.companyNameEng.contains(keyword) : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import codesquad.fineants.domain.stock.domain.dto.response.StockSearchItem;
import codesquad.fineants.domain.stock.domain.dto.response.StockSectorResponse;
import codesquad.fineants.domain.stock.domain.entity.Stock;
import codesquad.fineants.domain.stock.repository.StockQueryRepository;
import codesquad.fineants.domain.stock.repository.StockRepository;
import codesquad.fineants.global.errors.errorcode.StockErrorCode;
import codesquad.fineants.global.errors.exception.NotFoundResourceException;
Expand All @@ -43,6 +44,7 @@ public class StockService {
private final ClosingPriceRepository closingPriceRepository;
private final KrxService krxService;
private final StockDividendService stockDividendService;
private final StockQueryRepository stockQueryRepository;

@Transactional(readOnly = true)
public List<StockSearchItem> search(StockSearchRequest request) {
Expand All @@ -52,6 +54,13 @@ public List<StockSearchItem> search(StockSearchRequest request) {
.collect(Collectors.toList());
}

@Transactional(readOnly = true)
public List<StockSearchItem> search(String tickerSymbol, int size, String keyword) {
return stockQueryRepository.getSliceOfStock(tickerSymbol, size, keyword).stream()
.map(StockSearchItem::from)
.collect(Collectors.toList());
}

@Transactional(readOnly = true)
public StockResponse getStock(String tickerSymbol) {
Stock stock = stockRepository.findByTickerSymbol(tickerSymbol)
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/codesquad/fineants/global/config/SpringConfig.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
package codesquad.fineants.global.config;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

import com.querydsl.jpa.impl.JPAQueryFactory;

import codesquad.fineants.domain.portfolio.properties.PortfolioProperties;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;

@EnableAspectJAutoProxy
@EnableConfigurationProperties(value = PortfolioProperties.class)
@Configuration
public class SpringConfig {

@PersistenceContext
private EntityManager entityManager;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.ResultActions;

import codesquad.fineants.docs.RestDocsSupport;
import codesquad.fineants.domain.common.money.Money;
Expand All @@ -33,6 +34,7 @@
import codesquad.fineants.domain.stock.domain.dto.response.StockRefreshResponse;
import codesquad.fineants.domain.stock.domain.dto.response.StockResponse;
import codesquad.fineants.domain.stock.domain.dto.response.StockSearchItem;
import codesquad.fineants.domain.stock.domain.entity.Market;
import codesquad.fineants.domain.stock.domain.entity.Stock;
import codesquad.fineants.domain.stock.service.StockService;
import codesquad.fineants.global.util.ObjectMapperUtil;
Expand Down Expand Up @@ -103,7 +105,96 @@ void search() throws Exception {
)
)
);
}

@DisplayName("종목 스크롤 검색 API")
@Test
void scrollSearch() throws Exception {
// given
String tickerSymbol = "";
int size = 10;
String keyword = "삼성";
List<StockSearchItem> stockSearchItemList = createStockSearchItemList();
given(service.search(tickerSymbol, size, keyword))
.willReturn(stockSearchItemList);

// when & then
ResultActions resultActions = mockMvc.perform(get("/api/stocks/search")
.queryParam("tickerSymbol", tickerSymbol)
.queryParam("size", "10")
.queryParam("keyword", keyword)
);
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("code").value(equalTo(200)))
.andExpect(jsonPath("status").value(equalTo("OK")))
.andExpect(jsonPath("message").value(equalTo("종목 검색이 완료되었습니다")))
.andExpect(jsonPath("data").isArray());

// Validate the data array content
for (int i = 0; i < stockSearchItemList.size(); i++) {
StockSearchItem expectedItem = stockSearchItemList.get(i);
resultActions.andExpect(jsonPath("data[" + i + "].stockCode").value(equalTo(expectedItem.getStockCode())))
.andExpect(jsonPath("data[" + i + "].tickerSymbol").value(equalTo(expectedItem.getTickerSymbol())))
.andExpect(jsonPath("data[" + i + "].companyName").value(equalTo(expectedItem.getCompanyName())))
.andExpect(jsonPath("data[" + i + "].companyNameEng").value(equalTo(expectedItem.getCompanyNameEng())))
.andExpect(jsonPath("data[" + i + "].market").value(equalTo(expectedItem.getMarket().name())));
}

resultActions.andDo(
document(
"stock-scroll-search",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
queryParameters(
parameterWithName("tickerSymbol").description("종목 리스트 중 가장 마지막 종목의 tickerSymbol").optional(),
parameterWithName("size").description("종목 검색시 검색할 최대 종목 개수").optional(),
parameterWithName("keyword").description("종목 검색 키워드").optional()
),
responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER)
.description("코드"),
fieldWithPath("status").type(JsonFieldType.STRING)
.description("상태"),
fieldWithPath("message").type(JsonFieldType.STRING)
.description("메시지"),
fieldWithPath("data").type(JsonFieldType.ARRAY)
.description("응답 데이터"),
fieldWithPath("data[].stockCode").type(JsonFieldType.STRING)
.description("종목 코드"),
fieldWithPath("data[].tickerSymbol").type(JsonFieldType.STRING)
.description("종목 티커 심볼"),
fieldWithPath("data[].companyName").type(JsonFieldType.STRING)
.description("종목 이름"),
fieldWithPath("data[].companyNameEng").type(JsonFieldType.STRING)
.description("종목 이름 영문"),
fieldWithPath("data[].market").type(JsonFieldType.STRING)
.description("시장 종류")
)
)
);
}

private List<StockSearchItem> createStockSearchItemList() {
List<Stock> stocks = List.of(
Stock.of("468510", "삼성기업인수목적9호", "SAMSUNG SPECIAL PURPOSE ACQUISITION 9 COMPANY", "KR7468510003", "금융",
Market.KOSDAQ),
Stock.of("448740", "삼성기업인수목적8호", "SAMSUNG SPECIAL PURPOSE ACQUISITION 8 COMPANY", "KR7448740001", "금융",
Market.KOSDAQ),
Stock.of("448730", "삼성FN리츠보통주", "SamsungFN REIT", "KR7448730002", "서비스업", Market.KOSPI),
Stock.of("439250", "삼성기업인수목적7호", "SAMSUNG SPECIAL PURPOSE ACQUISITION 7 COMPANY", "KR7448730002", "금융",
Market.KOSDAQ),
Stock.of("425290", "삼성기업인수목적6호", "SAMSUNG SPECIAL PURPOSE ACQUISITION 6 COMPANY", "KR7425290004", "금융",
Market.KOSDAQ),
Stock.of("207940", "삼성바이오로직스보통주", "SAMSUNG BIOLOGICS", "KR7207940008", "의약품", Market.KOSPI),
Stock.of("068290", "삼성출판사보통주", "SAMSUNG PUBLISHING", "KR7068290006", "서비스업", Market.KOSPI),
Stock.of("032830", "삼성생명보험보통주", "Samsung Life Insurance", "KR7032830002", "보험", Market.KOSPI),
Stock.of("029780", "삼성카드 보통주", "\\\"SAMSUNG CARD CO.", "KR7029780004", "기타금융", Market.NONE),
Stock.of("02826K", "삼성물산1우선주(신형)", "SAMSUNG C&T CORPORATION(1PB)", "KR702826K016", "유통업", Market.KOSPI)
);
return stocks.stream()
.map(StockSearchItem::from)
.toList();
}

@DisplayName("종목 최신화 API")
Expand Down
Loading