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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -135,4 +137,39 @@ public ResponseEntity<LoginResponse> appleLogin(
}
}

}
@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<LoginResponse> 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();
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
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;

import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;

@Service
Expand All @@ -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);
Expand Down Expand Up @@ -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<User> 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<User> 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<String> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,22 +16,32 @@
public interface SearchKeywordRepository extends JpaRepository<SearchKeyword, Long> {

@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<SearchKeywordStatsResponse> 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<SearchKeywordStatsResponse> 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<SearchKeyword> findTopByKeywordAndCountryAndCreatedAtBetweenOrderByIdAsc(
String keyword,
COUNTRY country,
@Param("startOfDay") LocalDateTime startOfDay,
@Param("endOfDay") LocalDateTime endOfDay);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
Expand All @@ -54,6 +70,6 @@ public List<SearchKeywordStatsResponse> getTopSearchKeywordsByCountry(COUNTRY co

@Transactional(readOnly = true)
public Long getSearchCount(String keyword, COUNTRY country) {
return searchKeywordRepository.countByKeywordAndCountry(keyword, country);
return searchKeywordRepository.sumSearchCountByKeywordAndCountry(keyword, country);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,15 @@ public class StockService {

private final int LIMITS = 9;

@Cacheable(value = "searchResult", key = "#searchKeyword + '_' + #country", unless = "#result == null")
public Mono<StockInfoResponse> 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<StockInfoResponse> searchStockBySymbolNameCached(final String searchKeyword,
final String country) {
List<EXCHANGENUM> koreaExchanges = List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ,
EXCHANGENUM.KOREAN_ETF);
Expand All @@ -79,10 +86,6 @@ public Mono<StockInfoResponse> 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()));
Expand Down