Skip to content

Commit 9f45643

Browse files
authored
Merge pull request #240 from FunD-StockProject/feat/apple-form-post
Feat/apple form post
2 parents e77d428 + 6176aad commit 9f45643

6 files changed

Lines changed: 122 additions & 20 deletions

File tree

src/main/java/com/fund/stockProject/auth/controller/OAuth2Controller.java

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import lombok.RequiredArgsConstructor;
66
import lombok.extern.slf4j.Slf4j;
77
import org.springframework.http.HttpStatus;
8+
import org.springframework.http.MediaType;
89
import org.springframework.http.ResponseEntity;
910
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.PostMapping;
1012
import org.springframework.web.bind.annotation.RequestMapping;
1113
import org.springframework.web.bind.annotation.RequestParam;
1214
import org.springframework.web.bind.annotation.RestController;
@@ -135,4 +137,39 @@ public ResponseEntity<LoginResponse> appleLogin(
135137
}
136138
}
137139

138-
}
140+
@PostMapping(value = "/login/apple", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
141+
@Operation(summary = "애플 로그인 (form_post)", description = "애플 form_post 응답을 처리합니다.")
142+
@ApiResponses({
143+
@ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = LoginResponse.class))),
144+
@ApiResponse(responseCode = "404", description = "회원가입 필요", content = @Content(schema = @Schema(implementation = LoginResponse.class))),
145+
@ApiResponse(responseCode = "500", description = "외부 연동/서버 오류")
146+
})
147+
public ResponseEntity<LoginResponse> appleLoginFormPost(
148+
@Parameter(description = "인가 코드", example = "c1d2e3f4...") @RequestParam String code,
149+
@Parameter(description = "redirect uri", example = "http://localhost:5173/login/oauth2/code/apple") @RequestParam String state,
150+
@Parameter(description = "user") @RequestParam(required = false) String user,
151+
@Parameter(description = "error", example = "invalid_request") @RequestParam(required = false) String error,
152+
@Parameter(description = "error_description") @RequestParam(required = false, name = "error_description") String errorDescription) {
153+
if (error != null && !error.isBlank()) {
154+
log.warn("Apple login form_post error: {} - {}", error, errorDescription);
155+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
156+
}
157+
158+
try {
159+
log.info("Apple login (form_post) attempt - state: {}", state);
160+
LoginResponse response = oAuth2Service.appleLogin(code, state, user);
161+
162+
if ("NEED_REGISTER".equals(response.getState())) {
163+
log.info("Apple login (form_post) - registration required");
164+
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
165+
}
166+
167+
log.info("Apple login (form_post) successful");
168+
return ResponseEntity.ok(response);
169+
} catch (Exception e) {
170+
log.error("Apple login (form_post) failed", e);
171+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
172+
}
173+
}
174+
175+
}

src/main/java/com/fund/stockProject/auth/service/OAuth2Service.java

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
import com.fund.stockProject.auth.oauth2.KakaoOAuth2UserInfo;
99
import com.fund.stockProject.auth.oauth2.NaverOAuth2UserInfo;
1010
import com.fund.stockProject.user.repository.UserRepository;
11+
import com.fasterxml.jackson.databind.JsonNode;
12+
import com.fasterxml.jackson.databind.ObjectMapper;
1113
import lombok.RequiredArgsConstructor;
1214
import org.springframework.stereotype.Service;
1315

1416
import java.io.UnsupportedEncodingException;
1517
import java.nio.charset.StandardCharsets;
1618
import java.util.Base64;
1719
import java.util.Map;
18-
import java.util.NoSuchElementException;
1920
import java.util.Optional;
2021

2122
@Service
@@ -27,6 +28,7 @@ public class OAuth2Service {
2728
private final NaverService naverService;
2829
private final GoogleService googleService;
2930
private final AppleService appleService;
31+
private final ObjectMapper objectMapper;
3032

3133
public LoginResponse kakaoLogin(String code, String state) {
3234
String redirectUri = decodeState(state);
@@ -83,26 +85,52 @@ public LoginResponse googleLogin(String code, String state) {
8385
}
8486

8587
public LoginResponse appleLogin(String code, String state) {
88+
return appleLogin(code, state, null);
89+
}
90+
91+
public LoginResponse appleLogin(String code, String state, String userJson) {
8692
String redirectUri = decodeState(state);
8793
// 1. 애플로 code + client_secret을 보내서 토큰(및 id_token) 받기
8894
AppleTokenResponse response = appleService.getAccessToken(code, redirectUri);
8995
// 2. id_token(JWT)에서 사용자 정보 추출 (이메일, sub=providerId 등)
9096
AppleOAuth2UserInfo appleUserInfo = appleService.getUserInfoFromIdToken(response.getIdToken());
9197

92-
Optional<User> userOptional = userRepository.findByEmail(appleUserInfo.getEmail());
98+
String email = appleUserInfo.getEmail();
99+
if ((email == null || email.isBlank()) && userJson != null && !userJson.isBlank()) {
100+
email = extractAppleEmail(userJson).orElse(null);
101+
}
102+
if (email == null || email.isBlank()) {
103+
throw new IllegalStateException("Apple email is missing");
104+
}
105+
106+
Optional<User> userOptional = userRepository.findByEmail(email);
93107
if (userOptional.isEmpty()) {
94-
return new LoginResponse("NEED_REGISTER", appleUserInfo.getEmail(), null, null, null, null);
108+
return new LoginResponse("NEED_REGISTER", email, null, null, null, null);
95109
}
96110

97111
User user = userOptional.get();
98112
user.updateSocialUserInfo(PROVIDER.APPLE, appleUserInfo.getProviderId(), response.getAccessToken(), response.getRefreshToken());
99113
userRepository.save(user);
100114

101-
return tokenService.issueTokensOnLogin(user.getEmail(), user.getRole(), null);
115+
return tokenService.issueTokensOnLogin(email, user.getRole(), null);
102116
}
103117

104118

105119

120+
private Optional<String> extractAppleEmail(String userJson) {
121+
try {
122+
JsonNode root = objectMapper.readTree(userJson);
123+
JsonNode emailNode = root.get("email");
124+
if (emailNode == null || emailNode.isNull()) {
125+
return Optional.empty();
126+
}
127+
String email = emailNode.asText();
128+
return email == null || email.isBlank() ? Optional.empty() : Optional.of(email);
129+
} catch (Exception e) {
130+
return Optional.empty();
131+
}
132+
}
133+
106134

107135
private String decodeState(String encodedState) {
108136
try {

src/main/java/com/fund/stockProject/searchkeyword/entity/SearchKeyword.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,18 @@ public class SearchKeyword extends Core {
4040
@Column(nullable = false, length = 20)
4141
private COUNTRY country;
4242

43+
@Column(name = "search_count")
44+
private Long searchCount;
45+
4346
public static SearchKeyword of(String keyword, COUNTRY country) {
4447
return SearchKeyword.builder()
4548
.keyword(keyword)
4649
.country(country)
50+
.searchCount(1L)
4751
.build();
4852
}
53+
54+
public void updateSearchCount(long searchCount) {
55+
this.searchCount = searchCount;
56+
}
4957
}

src/main/java/com/fund/stockProject/searchkeyword/repository/SearchKeywordRepository.java

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import java.time.LocalDateTime;
44
import java.util.List;
5-
5+
import java.util.Optional;
66
import org.springframework.data.jpa.repository.JpaRepository;
77
import org.springframework.data.jpa.repository.Query;
88
import org.springframework.data.repository.query.Param;
@@ -16,22 +16,32 @@
1616
public interface SearchKeywordRepository extends JpaRepository<SearchKeyword, Long> {
1717

1818
@Query("SELECT new com.fund.stockProject.searchkeyword.dto.response.SearchKeywordStatsResponse(" +
19-
"s.keyword, s.country, COUNT(s)) " +
19+
"s.keyword, s.country, SUM(COALESCE(s.searchCount, 1))) " +
2020
"FROM SearchKeyword s " +
2121
"WHERE s.createdAt >= :startDate " +
2222
"GROUP BY s.keyword, s.country " +
23-
"ORDER BY COUNT(s) DESC")
23+
"ORDER BY SUM(COALESCE(s.searchCount, 1)) DESC")
2424
List<SearchKeywordStatsResponse> findTopSearchKeywords(@Param("startDate") LocalDateTime startDate);
2525

2626
@Query("SELECT new com.fund.stockProject.searchkeyword.dto.response.SearchKeywordStatsResponse(" +
27-
"s.keyword, s.country, COUNT(s)) " +
27+
"s.keyword, s.country, SUM(COALESCE(s.searchCount, 1))) " +
2828
"FROM SearchKeyword s " +
2929
"WHERE s.createdAt >= :startDate AND s.country = :country " +
3030
"GROUP BY s.keyword, s.country " +
31-
"ORDER BY COUNT(s) DESC")
31+
"ORDER BY SUM(COALESCE(s.searchCount, 1)) DESC")
3232
List<SearchKeywordStatsResponse> findTopSearchKeywordsByCountry(
3333
@Param("startDate") LocalDateTime startDate,
3434
@Param("country") COUNTRY country);
3535

36-
Long countByKeywordAndCountry(String keyword, COUNTRY country);
36+
@Query("SELECT COALESCE(SUM(COALESCE(s.searchCount, 1)), 0) FROM SearchKeyword s " +
37+
"WHERE s.keyword = :keyword AND s.country = :country")
38+
Long sumSearchCountByKeywordAndCountry(
39+
@Param("keyword") String keyword,
40+
@Param("country") COUNTRY country);
41+
42+
Optional<SearchKeyword> findTopByKeywordAndCountryAndCreatedAtBetweenOrderByIdAsc(
43+
String keyword,
44+
COUNTRY country,
45+
@Param("startOfDay") LocalDateTime startOfDay,
46+
@Param("endOfDay") LocalDateTime endOfDay);
3747
}

src/main/java/com/fund/stockProject/searchkeyword/service/SearchKeywordService.java

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.fund.stockProject.searchkeyword.service;
22

33
import java.time.LocalDateTime;
4+
import java.time.LocalTime;
45
import java.util.List;
56

67
import org.springframework.scheduling.annotation.Async;
@@ -26,9 +27,24 @@ public class SearchKeywordService {
2627
@Transactional
2728
public void saveSearchKeyword(String keyword, COUNTRY country) {
2829
try {
29-
SearchKeyword searchKeyword = SearchKeyword.of(keyword, country);
30-
searchKeywordRepository.save(searchKeyword);
31-
log.debug("Saved search keyword: {} ({})", keyword, country);
30+
LocalDateTime now = LocalDateTime.now();
31+
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
32+
LocalDateTime endOfDay = now.toLocalDate().atTime(LocalTime.MAX);
33+
34+
SearchKeyword searchKeyword = searchKeywordRepository
35+
.findTopByKeywordAndCountryAndCreatedAtBetweenOrderByIdAsc(
36+
keyword, country, startOfDay, endOfDay)
37+
.orElse(null);
38+
39+
if (searchKeyword == null) {
40+
searchKeywordRepository.save(SearchKeyword.of(keyword, country));
41+
log.debug("Saved search keyword: {} ({})", keyword, country);
42+
return;
43+
}
44+
45+
Long currentCount = searchKeyword.getSearchCount();
46+
long baseCount = currentCount == null ? 1L : currentCount;
47+
searchKeyword.updateSearchCount(baseCount + 1);
3248
} catch (Exception e) {
3349
log.error("Failed to save search keyword: {} ({})", keyword, country, e);
3450
}
@@ -54,6 +70,6 @@ public List<SearchKeywordStatsResponse> getTopSearchKeywordsByCountry(COUNTRY co
5470

5571
@Transactional(readOnly = true)
5672
public Long getSearchCount(String keyword, COUNTRY country) {
57-
return searchKeywordRepository.countByKeywordAndCountry(keyword, country);
73+
return searchKeywordRepository.sumSearchCountByKeywordAndCountry(keyword, country);
5874
}
5975
}

src/main/java/com/fund/stockProject/stock/service/StockService.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,15 @@ public class StockService {
6666

6767
private final int LIMITS = 9;
6868

69-
@Cacheable(value = "searchResult", key = "#searchKeyword + '_' + #country", unless = "#result == null")
7069
public Mono<StockInfoResponse> searchStockBySymbolName(final String searchKeyword,
70+
final String country) {
71+
return searchStockBySymbolNameCached(searchKeyword, country)
72+
.doOnNext(result -> searchKeywordService.saveSearchKeyword(
73+
searchKeyword, COUNTRY.valueOf(country)));
74+
}
75+
76+
@Cacheable(value = "searchResult", key = "#searchKeyword + '_' + #country", unless = "#result == null")
77+
public Mono<StockInfoResponse> searchStockBySymbolNameCached(final String searchKeyword,
7178
final String country) {
7279
List<EXCHANGENUM> koreaExchanges = List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ,
7380
EXCHANGENUM.KOREAN_ETF);
@@ -79,10 +86,6 @@ public Mono<StockInfoResponse> searchStockBySymbolName(final String searchKeywor
7986

8087
if (bySymbolNameAndCountryWithEnums.isPresent()) {
8188
final Stock stock = bySymbolNameAndCountryWithEnums.get();
82-
83-
COUNTRY countryEnum = COUNTRY.valueOf(country);
84-
searchKeywordService.saveSearchKeyword(searchKeyword, countryEnum);
85-
8689
return securityService.getSecurityStockInfoKorea(stock.getId(), stock.getSymbolName(),
8790
stock.getSecurityName(), stock.getSymbol(), stock.getExchangeNum(),
8891
getCountryFromExchangeNum(stock.getExchangeNum()));

0 commit comments

Comments
 (0)