From 12e77d580a6b0eb840aa958e8695932df134d4b4 Mon Sep 17 00:00:00 2001 From: MuuiGong Date: Sun, 11 Jan 2026 19:10:10 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Feat:=20=EA=B2=80=EC=83=89=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=EB=A5=BC=20=EC=9C=84=ED=95=9C=20search=20count=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../searchkeyword/entity/SearchKeyword.java | 8 +++++++ .../repository/SearchKeywordRepository.java | 22 ++++++++++++----- .../service/SearchKeywordService.java | 24 +++++++++++++++---- .../stock/service/StockService.java | 13 ++++++---- 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/fund/stockProject/searchkeyword/entity/SearchKeyword.java b/src/main/java/com/fund/stockProject/searchkeyword/entity/SearchKeyword.java index 966e32e..fa4ed8f 100644 --- a/src/main/java/com/fund/stockProject/searchkeyword/entity/SearchKeyword.java +++ b/src/main/java/com/fund/stockProject/searchkeyword/entity/SearchKeyword.java @@ -40,10 +40,18 @@ public class SearchKeyword extends Core { @Column(nullable = false, length = 20) private COUNTRY country; + @Column(name = "search_count") + private Long searchCount; + public static SearchKeyword of(String keyword, COUNTRY country) { return SearchKeyword.builder() .keyword(keyword) .country(country) + .searchCount(1L) .build(); } + + public void updateSearchCount(long searchCount) { + this.searchCount = searchCount; + } } diff --git a/src/main/java/com/fund/stockProject/searchkeyword/repository/SearchKeywordRepository.java b/src/main/java/com/fund/stockProject/searchkeyword/repository/SearchKeywordRepository.java index 95ba002..cfd3eb3 100644 --- a/src/main/java/com/fund/stockProject/searchkeyword/repository/SearchKeywordRepository.java +++ b/src/main/java/com/fund/stockProject/searchkeyword/repository/SearchKeywordRepository.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import java.util.List; - +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,22 +16,32 @@ public interface SearchKeywordRepository extends JpaRepository { @Query("SELECT new com.fund.stockProject.searchkeyword.dto.response.SearchKeywordStatsResponse(" + - "s.keyword, s.country, COUNT(s)) " + + "s.keyword, s.country, SUM(COALESCE(s.searchCount, 1))) " + "FROM SearchKeyword s " + "WHERE s.createdAt >= :startDate " + "GROUP BY s.keyword, s.country " + - "ORDER BY COUNT(s) DESC") + "ORDER BY SUM(COALESCE(s.searchCount, 1)) DESC") List findTopSearchKeywords(@Param("startDate") LocalDateTime startDate); @Query("SELECT new com.fund.stockProject.searchkeyword.dto.response.SearchKeywordStatsResponse(" + - "s.keyword, s.country, COUNT(s)) " + + "s.keyword, s.country, SUM(COALESCE(s.searchCount, 1))) " + "FROM SearchKeyword s " + "WHERE s.createdAt >= :startDate AND s.country = :country " + "GROUP BY s.keyword, s.country " + - "ORDER BY COUNT(s) DESC") + "ORDER BY SUM(COALESCE(s.searchCount, 1)) DESC") List findTopSearchKeywordsByCountry( @Param("startDate") LocalDateTime startDate, @Param("country") COUNTRY country); - Long countByKeywordAndCountry(String keyword, COUNTRY country); + @Query("SELECT COALESCE(SUM(COALESCE(s.searchCount, 1)), 0) FROM SearchKeyword s " + + "WHERE s.keyword = :keyword AND s.country = :country") + Long sumSearchCountByKeywordAndCountry( + @Param("keyword") String keyword, + @Param("country") COUNTRY country); + + Optional findTopByKeywordAndCountryAndCreatedAtBetweenOrderByIdAsc( + String keyword, + COUNTRY country, + @Param("startOfDay") LocalDateTime startOfDay, + @Param("endOfDay") LocalDateTime endOfDay); } diff --git a/src/main/java/com/fund/stockProject/searchkeyword/service/SearchKeywordService.java b/src/main/java/com/fund/stockProject/searchkeyword/service/SearchKeywordService.java index 34e44a0..05f1e40 100644 --- a/src/main/java/com/fund/stockProject/searchkeyword/service/SearchKeywordService.java +++ b/src/main/java/com/fund/stockProject/searchkeyword/service/SearchKeywordService.java @@ -1,6 +1,7 @@ package com.fund.stockProject.searchkeyword.service; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import org.springframework.scheduling.annotation.Async; @@ -26,9 +27,24 @@ public class SearchKeywordService { @Transactional public void saveSearchKeyword(String keyword, COUNTRY country) { try { - SearchKeyword searchKeyword = SearchKeyword.of(keyword, country); - searchKeywordRepository.save(searchKeyword); - log.debug("Saved search keyword: {} ({})", keyword, country); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime startOfDay = now.toLocalDate().atStartOfDay(); + LocalDateTime endOfDay = now.toLocalDate().atTime(LocalTime.MAX); + + SearchKeyword searchKeyword = searchKeywordRepository + .findTopByKeywordAndCountryAndCreatedAtBetweenOrderByIdAsc( + keyword, country, startOfDay, endOfDay) + .orElse(null); + + if (searchKeyword == null) { + searchKeywordRepository.save(SearchKeyword.of(keyword, country)); + log.debug("Saved search keyword: {} ({})", keyword, country); + return; + } + + Long currentCount = searchKeyword.getSearchCount(); + long baseCount = currentCount == null ? 1L : currentCount; + searchKeyword.updateSearchCount(baseCount + 1); } catch (Exception e) { log.error("Failed to save search keyword: {} ({})", keyword, country, e); } @@ -54,6 +70,6 @@ public List getTopSearchKeywordsByCountry(COUNTRY co @Transactional(readOnly = true) public Long getSearchCount(String keyword, COUNTRY country) { - return searchKeywordRepository.countByKeywordAndCountry(keyword, country); + return searchKeywordRepository.sumSearchCountByKeywordAndCountry(keyword, country); } } diff --git a/src/main/java/com/fund/stockProject/stock/service/StockService.java b/src/main/java/com/fund/stockProject/stock/service/StockService.java index b2bd89b..77d2140 100644 --- a/src/main/java/com/fund/stockProject/stock/service/StockService.java +++ b/src/main/java/com/fund/stockProject/stock/service/StockService.java @@ -66,8 +66,15 @@ public class StockService { private final int LIMITS = 9; - @Cacheable(value = "searchResult", key = "#searchKeyword + '_' + #country", unless = "#result == null") public Mono searchStockBySymbolName(final String searchKeyword, + final String country) { + return searchStockBySymbolNameCached(searchKeyword, country) + .doOnNext(result -> searchKeywordService.saveSearchKeyword( + searchKeyword, COUNTRY.valueOf(country))); + } + + @Cacheable(value = "searchResult", key = "#searchKeyword + '_' + #country", unless = "#result == null") + public Mono searchStockBySymbolNameCached(final String searchKeyword, final String country) { List koreaExchanges = List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ, EXCHANGENUM.KOREAN_ETF); @@ -79,10 +86,6 @@ public Mono searchStockBySymbolName(final String searchKeywor if (bySymbolNameAndCountryWithEnums.isPresent()) { final Stock stock = bySymbolNameAndCountryWithEnums.get(); - - COUNTRY countryEnum = COUNTRY.valueOf(country); - searchKeywordService.saveSearchKeyword(searchKeyword, countryEnum); - return securityService.getSecurityStockInfoKorea(stock.getId(), stock.getSymbolName(), stock.getSecurityName(), stock.getSymbol(), stock.getExchangeNum(), getCountryFromExchangeNum(stock.getExchangeNum())); From 6176aad1894c50b73db56c9b6bc5719546dfbfd8 Mon Sep 17 00:00:00 2001 From: MuuiGong Date: Sun, 11 Jan 2026 20:46:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Feat:=20=EC=95=A0=ED=94=8C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20form=5Fpost=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/OAuth2Controller.java | 39 ++++++++++++++++++- .../auth/service/OAuth2Service.java | 36 +++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/fund/stockProject/auth/controller/OAuth2Controller.java b/src/main/java/com/fund/stockProject/auth/controller/OAuth2Controller.java index 2898611..ad53e8b 100644 --- a/src/main/java/com/fund/stockProject/auth/controller/OAuth2Controller.java +++ b/src/main/java/com/fund/stockProject/auth/controller/OAuth2Controller.java @@ -5,8 +5,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -135,4 +137,39 @@ public ResponseEntity appleLogin( } } -} \ No newline at end of file + @PostMapping(value = "/login/apple", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + @Operation(summary = "애플 로그인 (form_post)", description = "애플 form_post 응답을 처리합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = LoginResponse.class))), + @ApiResponse(responseCode = "404", description = "회원가입 필요", content = @Content(schema = @Schema(implementation = LoginResponse.class))), + @ApiResponse(responseCode = "500", description = "외부 연동/서버 오류") + }) + public ResponseEntity appleLoginFormPost( + @Parameter(description = "인가 코드", example = "c1d2e3f4...") @RequestParam String code, + @Parameter(description = "redirect uri", example = "http://localhost:5173/login/oauth2/code/apple") @RequestParam String state, + @Parameter(description = "user") @RequestParam(required = false) String user, + @Parameter(description = "error", example = "invalid_request") @RequestParam(required = false) String error, + @Parameter(description = "error_description") @RequestParam(required = false, name = "error_description") String errorDescription) { + if (error != null && !error.isBlank()) { + log.warn("Apple login form_post error: {} - {}", error, errorDescription); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + + try { + log.info("Apple login (form_post) attempt - state: {}", state); + LoginResponse response = oAuth2Service.appleLogin(code, state, user); + + if ("NEED_REGISTER".equals(response.getState())) { + log.info("Apple login (form_post) - registration required"); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + log.info("Apple login (form_post) successful"); + return ResponseEntity.ok(response); + } catch (Exception e) { + log.error("Apple login (form_post) failed", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + +} diff --git a/src/main/java/com/fund/stockProject/auth/service/OAuth2Service.java b/src/main/java/com/fund/stockProject/auth/service/OAuth2Service.java index 6942271..956062e 100644 --- a/src/main/java/com/fund/stockProject/auth/service/OAuth2Service.java +++ b/src/main/java/com/fund/stockProject/auth/service/OAuth2Service.java @@ -8,6 +8,8 @@ import com.fund.stockProject.auth.oauth2.KakaoOAuth2UserInfo; import com.fund.stockProject.auth.oauth2.NaverOAuth2UserInfo; import com.fund.stockProject.user.repository.UserRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -15,7 +17,6 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Map; -import java.util.NoSuchElementException; import java.util.Optional; @Service @@ -27,6 +28,7 @@ public class OAuth2Service { private final NaverService naverService; private final GoogleService googleService; private final AppleService appleService; + private final ObjectMapper objectMapper; public LoginResponse kakaoLogin(String code, String state) { String redirectUri = decodeState(state); @@ -83,26 +85,52 @@ public LoginResponse googleLogin(String code, String state) { } public LoginResponse appleLogin(String code, String state) { + return appleLogin(code, state, null); + } + + public LoginResponse appleLogin(String code, String state, String userJson) { String redirectUri = decodeState(state); // 1. 애플로 code + client_secret을 보내서 토큰(및 id_token) 받기 AppleTokenResponse response = appleService.getAccessToken(code, redirectUri); // 2. id_token(JWT)에서 사용자 정보 추출 (이메일, sub=providerId 등) AppleOAuth2UserInfo appleUserInfo = appleService.getUserInfoFromIdToken(response.getIdToken()); - Optional userOptional = userRepository.findByEmail(appleUserInfo.getEmail()); + String email = appleUserInfo.getEmail(); + if ((email == null || email.isBlank()) && userJson != null && !userJson.isBlank()) { + email = extractAppleEmail(userJson).orElse(null); + } + if (email == null || email.isBlank()) { + throw new IllegalStateException("Apple email is missing"); + } + + Optional userOptional = userRepository.findByEmail(email); if (userOptional.isEmpty()) { - return new LoginResponse("NEED_REGISTER", appleUserInfo.getEmail(), null, null, null, null); + return new LoginResponse("NEED_REGISTER", email, null, null, null, null); } User user = userOptional.get(); user.updateSocialUserInfo(PROVIDER.APPLE, appleUserInfo.getProviderId(), response.getAccessToken(), response.getRefreshToken()); userRepository.save(user); - return tokenService.issueTokensOnLogin(user.getEmail(), user.getRole(), null); + return tokenService.issueTokensOnLogin(email, user.getRole(), null); } + private Optional extractAppleEmail(String userJson) { + try { + JsonNode root = objectMapper.readTree(userJson); + JsonNode emailNode = root.get("email"); + if (emailNode == null || emailNode.isNull()) { + return Optional.empty(); + } + String email = emailNode.asText(); + return email == null || email.isBlank() ? Optional.empty() : Optional.of(email); + } catch (Exception e) { + return Optional.empty(); + } + } + private String decodeState(String encodedState) { try {