Skip to content

Commit bbd84bd

Browse files
committed
(#59) 배치 점수 재계산 시 username 변경 사용자 fallback 복구 로직 추가
* feature: GraphQLQueryBuilder에 node(id:) 기반 사용자 조회 쿼리 추가 배치에서 username 변경으로 USER_NOT_FOUND 발생 시 nodeId로 현재 login을 복구하기 위한 GraphQL node(id:) 쿼리와 응답 DTO 추가 * feature: GitHubGraphQLClient에 nodeId 기반 사용자 조회 메서드 추가 node(id:) GraphQL 쿼리를 실행하여 nodeId로 현재 login, avatarUrl을 조회하는 getUserInfoByNodeId 메서드 추가 * feature: GitHubActivityService에 nodeId 기반 사용자 조회 메서드 추가 fetchUserByNodeId 메서드를 추가하여 GraphQLClient의 nodeId 조회를 서비스 레이어에서 위임 * feature: ScoreRecalculationProcessor에 username 변경 fallback 로직 추가 GITHUB_USER_NOT_FOUND 발생 시 nodeId로 현재 login을 조회하여 프로필 업데이트 후 점수 재계산을 1회 재시도하도록 변경 * test: ScoreRecalculationProcessor username 변경 fallback 단위 테스트 추가 nodeId 기반 username 복구 성공, nodeId 조회 실패, 재시도 실패 케이스 테스트 추가 및 기존 NonRetryable 테스트를 USER_NOT_FOUND 외 케이스로 수정 * feature: nodeId 기반 사용자 조회 시 email 필드도 함께 조회하도록 보강 GraphQL node(id:) 쿼리, DTO, processor fallback 로직에서 email도 함께 가져와 프로필 업데이트 시 username, profileImage, email 모두 반영 * fix: OAuth 로그인 시 email 변경이 실제로 반영되지 않던 버그 수정 isInfoChanged에서 email 변경을 감지하지만 updateProfile 호출 시 email을 null로 전달하여 실제 업데이트가 누락되던 문제를 수정 * fix: 코드 리뷰 반영 - NPE 방어, 테스트 보강, 가독성 개선 - GitHubNodeUserResponse accessor 메서드에 null 방어 로직 추가 - nodeId 조회 실패 테스트에서 실제 API 응답과 동일하게 node=null로 수정 - isInfoChanged 이메일 비교 조건에 명시적 괄호 추가 - fallback 성공 테스트에 email assertion 추가 - 재시도 실패 테스트에 fallback/retry verify 추가 - UserPersistenceServiceTest에 email 변경 assertion 추가 * fix: getUserInfoByNodeId에서 response.data() null 체크 추가 GraphQL execution error 시 data:null 반환될 수 있어 rateLimit 접근 전 null 가드 추가하여 배치 fallback 경로에서 NPE 방지
2 parents 7299244 + 08c12c2 commit bbd84bd

10 files changed

Lines changed: 250 additions & 25 deletions

File tree

src/main/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessor.java

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import com.gitranker.api.global.error.exception.BusinessException;
1515
import com.gitranker.api.global.error.exception.GitHubApiNonRetryableException;
1616
import com.gitranker.api.global.error.exception.GitHubApiRetryableException;
17+
import com.gitranker.api.infrastructure.github.GitHubActivityService;
18+
import com.gitranker.api.infrastructure.github.dto.GitHubNodeUserResponse;
1719
import lombok.RequiredArgsConstructor;
1820
import lombok.extern.slf4j.Slf4j;
1921
import org.springframework.batch.item.ItemProcessor;
@@ -30,32 +32,56 @@ public class ScoreRecalculationProcessor implements ItemProcessor<User, User> {
3032
private final ActivityLogService activityLogService;
3133
private final IncrementalActivityUpdateStrategy incrementalStrategy;
3234
private final FullActivityUpdateStrategy fullStrategy;
35+
private final GitHubActivityService gitHubActivityService;
3336

3437
@Override
3538
public User process(User user) {
3639
try {
37-
int oldScore = user.getTotalScore();
38-
int currentYear = LocalDate.now().getYear();
40+
return recalculateScore(user);
41+
} catch (GitHubApiNonRetryableException e) {
42+
if (e.getErrorType() == ErrorType.GITHUB_USER_NOT_FOUND) {
43+
return handleUsernameChanged(user);
44+
}
45+
throw e;
46+
} catch (GitHubApiRetryableException e) {
47+
throw e;
48+
} catch (Exception e) {
49+
throw new BusinessException(ErrorType.BATCH_STEP_FAILED, "사용자: " + user.getUsername());
50+
}
51+
}
3952

40-
ActivityStatistics previousStats = findPreviousStats(user);
41-
ActivityStatistics updateStats = executeUpdate(user, currentYear);
53+
private User recalculateScore(User user) {
54+
int oldScore = user.getTotalScore();
55+
int currentYear = LocalDate.now().getYear();
4256

43-
Score newScore = updateStats.calculateScore();
44-
user.updateScore(newScore);
57+
ActivityStatistics previousStats = findPreviousStats(user);
58+
ActivityStatistics updateStats = executeUpdate(user, currentYear);
4559

46-
ActivityStatistics diffStats = updateStats.calculateDiff(previousStats);
47-
activityLogService.saveActivityLog(user, updateStats, diffStats, LocalDate.now());
60+
Score newScore = updateStats.calculateScore();
61+
user.updateScore(newScore);
4862

49-
log.debug("점수 갱신 완료 - 사용자: {}, 변동: {}",
50-
user.getUsername(), newScore.differenceFrom(Score.of(oldScore)));
63+
ActivityStatistics diffStats = updateStats.calculateDiff(previousStats);
64+
activityLogService.saveActivityLog(user, updateStats, diffStats, LocalDate.now());
5165

52-
return user;
66+
log.debug("점수 갱신 완료 - 사용자: {}, 변동: {}",
67+
user.getUsername(), newScore.differenceFrom(Score.of(oldScore)));
5368

54-
} catch (GitHubApiRetryableException | GitHubApiNonRetryableException e) {
55-
throw e;
56-
} catch (Exception e) {
57-
throw new BusinessException(ErrorType.BATCH_STEP_FAILED, "사용자: " + user.getUsername());
69+
return user;
70+
}
71+
72+
private User handleUsernameChanged(User user) {
73+
String oldUsername = user.getUsername();
74+
75+
GitHubNodeUserResponse response = gitHubActivityService.fetchUserByNodeId(user.getNodeId());
76+
if (!response.hasUser()) {
77+
throw new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND);
5878
}
79+
80+
user.updateProfile(response.getLogin(), response.getAvatarUrl(), response.getEmail());
81+
log.info("사용자 프로필 변경 감지 - 기존 username: {}, 신규 username: {}, nodeId: {}",
82+
oldUsername, response.getLogin(), user.getNodeId());
83+
84+
return recalculateScore(user);
5985
}
6086

6187
private ActivityStatistics findPreviousStats(User user) {

src/main/java/com/gitranker/api/domain/user/service/UserPersistenceService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ public User saveNewUser(User newUser, ActivityStatistics totalStats, ActivitySta
3838
}
3939

4040
@Transactional
41-
public User updateProfile(User user, String newUsername, String newProfileImage) {
42-
user.updateProfile(newUsername, newProfileImage, null);
41+
public User updateProfile(User user, String newUsername, String newProfileImage, String newEmail) {
42+
user.updateProfile(newUsername, newProfileImage, newEmail);
4343

4444
return userRepository.save(user);
4545
}

src/main/java/com/gitranker/api/domain/user/service/UserRegistrationService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,13 @@ private RegisterUserResponse handleNewUser(OAuthAttributes attributes) {
6666
private RegisterUserResponse handleExistingUser(User user, OAuthAttributes attributes) {
6767
boolean isInfoChanged = !user.getUsername().equals(attributes.username()) ||
6868
!user.getProfileImage().equals(attributes.profileImage()) ||
69-
(attributes.email()) != null && !attributes.email().equals(user.getEmail());
69+
(attributes.email() != null && !attributes.email().equals(user.getEmail()));
7070

7171
User currentUser = user;
7272

7373
if (isInfoChanged) {
7474
log.debug("사용자 프로필 정보 변경 감지 - 업데이트 수행: 사용자: {}", user.getUsername());
75-
currentUser = userPersistenceService.updateProfile(user, attributes.username(), attributes.profileImage());
75+
currentUser = userPersistenceService.updateProfile(user, attributes.username(), attributes.profileImage(), attributes.email());
7676
}
7777

7878
return createResponse(currentUser, false);

src/main/java/com/gitranker/api/infrastructure/github/GitHubActivityService.java

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

33
import com.gitranker.api.infrastructure.github.dto.GitHubActivitySummary;
44
import com.gitranker.api.infrastructure.github.dto.GitHubAllActivitiesResponse;
5+
import com.gitranker.api.infrastructure.github.dto.GitHubNodeUserResponse;
56
import com.gitranker.api.infrastructure.github.token.GitHubTokenPool;
67
import lombok.RequiredArgsConstructor;
78
import lombok.extern.slf4j.Slf4j;
@@ -35,6 +36,15 @@ public GitHubAllActivitiesResponse fetchRawAllActivities(String username, LocalD
3536
return response;
3637
}
3738

39+
public GitHubNodeUserResponse fetchUserByNodeId(String nodeId) {
40+
String token = tokenPool.getToken();
41+
GitHubNodeUserResponse response = graphQLClient.getUserInfoByNodeId(token, nodeId);
42+
43+
log.debug("nodeId 기반 사용자 조회 완료 - nodeId: {}, login: {}", nodeId, response.getLogin());
44+
45+
return response;
46+
}
47+
3848
public GitHubActivitySummary toSummary(GitHubAllActivitiesResponse response) {
3949
return new GitHubActivitySummary(
4050
response.getCommitCount(),

src/main/java/com/gitranker/api/infrastructure/github/GitHubGraphQLClient.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.gitranker.api.global.logging.LogContext;
1212
import com.gitranker.api.infrastructure.github.dto.GitHubAllActivitiesResponse;
1313
import com.gitranker.api.infrastructure.github.dto.GitHubGraphQLRequest;
14+
import com.gitranker.api.infrastructure.github.dto.GitHubNodeUserResponse;
1415
import com.gitranker.api.infrastructure.github.dto.GitHubRateLimitInfo;
1516
import com.gitranker.api.infrastructure.github.dto.GitHubUserInfoResponse;
1617
import com.gitranker.api.infrastructure.github.token.GitHubTokenPool;
@@ -90,6 +91,23 @@ public GitHubUserInfoResponse getUserInfo(String accessToken, String username) {
9091
return response;
9192
}
9293

94+
public GitHubNodeUserResponse getUserInfoByNodeId(String accessToken, String nodeId) {
95+
String query = queryBuilder.buildUserLookupByNodeIdQuery(nodeId);
96+
97+
GitHubNodeUserResponse response = executeQuery(accessToken, query, GitHubNodeUserResponse.class);
98+
99+
if (response.data() != null && response.data().rateLimit() != null) {
100+
recordRateLimitInfo(accessToken, response.data().rateLimit());
101+
102+
checkRateLimitSafety(
103+
response.data().rateLimit().remaining(),
104+
response.data().rateLimit().resetAt()
105+
);
106+
}
107+
108+
return response;
109+
}
110+
93111
public GitHubAllActivitiesResponse getAllActivities(String accessToken, String username, LocalDateTime githubJoinDate) {
94112
validateAccessToken(accessToken);
95113

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.gitranker.api.infrastructure.github.dto;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
5+
import java.time.LocalDateTime;
6+
7+
public record GitHubNodeUserResponse(
8+
@JsonProperty("data")
9+
Data data
10+
) {
11+
public String getLogin() {
12+
if (!hasUser()) return null;
13+
return data.node().login();
14+
}
15+
16+
public String getEmail() {
17+
if (!hasUser()) return null;
18+
return data.node().email();
19+
}
20+
21+
public String getAvatarUrl() {
22+
if (!hasUser()) return null;
23+
return data.node().avatarUrl();
24+
}
25+
26+
public boolean hasUser() {
27+
return data != null && data.node() != null && data.node().login() != null;
28+
}
29+
30+
public record Data(
31+
@JsonProperty("node") Node node,
32+
@JsonProperty("rateLimit") RateLimit rateLimit
33+
) {
34+
}
35+
36+
public record Node(
37+
@JsonProperty("id")
38+
String id,
39+
40+
@JsonProperty("login")
41+
String login,
42+
43+
@JsonProperty("email")
44+
String email,
45+
46+
@JsonProperty("avatarUrl")
47+
String avatarUrl
48+
) {
49+
}
50+
51+
public record RateLimit(
52+
int limit,
53+
int cost,
54+
int remaining,
55+
LocalDateTime resetAt
56+
) implements GitHubRateLimitInfo {
57+
}
58+
}

src/main/java/com/gitranker/api/infrastructure/github/util/GraphQLQueryBuilder.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,27 @@ public String buildBatchQuery(String username, int year) {
111111
);
112112
}
113113

114+
public String buildUserLookupByNodeIdQuery(String nodeId) {
115+
return String.format("""
116+
{
117+
rateLimit {
118+
limit
119+
remaining
120+
resetAt
121+
cost
122+
}
123+
node(id: "%s") {
124+
... on User {
125+
id
126+
login
127+
email
128+
avatarUrl
129+
}
130+
}
131+
}
132+
""", nodeId);
133+
}
134+
114135
private String buildFromDate(int year, int joinYear, LocalDateTime githubJoinDate) {
115136
if (year == joinYear && githubJoinDate != null) {
116137
return toISOString(githubJoinDate.atZone(appZoneId));

src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
import com.gitranker.api.global.error.exception.BusinessException;
1313
import com.gitranker.api.global.error.exception.GitHubApiNonRetryableException;
1414
import com.gitranker.api.global.error.exception.GitHubApiRetryableException;
15+
import com.gitranker.api.infrastructure.github.GitHubActivityService;
16+
import com.gitranker.api.infrastructure.github.dto.GitHubNodeUserResponse;
1517
import org.junit.jupiter.api.DisplayName;
18+
import org.junit.jupiter.api.Nested;
1619
import org.junit.jupiter.api.Test;
1720
import org.junit.jupiter.api.extension.ExtendWith;
1821
import org.mockito.InjectMocks;
@@ -39,6 +42,7 @@ class ScoreRecalculationProcessorTest {
3942
@Mock private ActivityLogService activityLogService;
4043
@Mock private IncrementalActivityUpdateStrategy incrementalStrategy;
4144
@Mock private FullActivityUpdateStrategy fullStrategy;
45+
@Mock private GitHubActivityService gitHubActivityService;
4246

4347
private User createUser() {
4448
return User.builder()
@@ -104,15 +108,15 @@ void should_rethrowRetryableException() {
104108
}
105109

106110
@Test
107-
@DisplayName("GitHubApiNonRetryableException은 그대로 전파된다")
108-
void should_rethrowNonRetryableException() {
111+
@DisplayName("GITHUB_USER_NOT_FOUND가 아닌 NonRetryableException은 그대로 전파된다")
112+
void should_rethrowNonRetryableException_when_notUserNotFound() {
109113
User user = createUser();
110114

111115
when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null);
112116
when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any()))
113117
.thenReturn(Optional.empty());
114118
when(fullStrategy.update(eq(user), any()))
115-
.thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND));
119+
.thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_COLLECT_ACTIVITY_FAILED));
116120

117121
assertThatThrownBy(() -> processor.process(user))
118122
.isInstanceOf(GitHubApiNonRetryableException.class);
@@ -132,4 +136,91 @@ void should_wrapInBusinessException_when_unexpectedError() {
132136
assertThatThrownBy(() -> processor.process(user))
133137
.isInstanceOf(BusinessException.class);
134138
}
139+
140+
@Nested
141+
@DisplayName("username 변경 fallback 테스트")
142+
class UsernameFallbackTest {
143+
144+
@Test
145+
@DisplayName("USER_NOT_FOUND 발생 시 nodeId로 현재 username을 조회하여 프로필 갱신 후 재계산한다")
146+
void should_retryWithNewUsername_when_userNotFound() {
147+
User user = createUser();
148+
ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3);
149+
150+
when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null);
151+
when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any()))
152+
.thenReturn(Optional.empty());
153+
154+
// 첫 번째 호출: USER_NOT_FOUND, 두 번째 호출(재시도): 성공
155+
when(fullStrategy.update(eq(user), any()))
156+
.thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND))
157+
.thenReturn(stats);
158+
159+
GitHubNodeUserResponse nodeResponse = new GitHubNodeUserResponse(
160+
new GitHubNodeUserResponse.Data(
161+
new GitHubNodeUserResponse.Node("node1", "newusername", "new@email.com", "https://new-avatar.png"),
162+
null
163+
)
164+
);
165+
when(gitHubActivityService.fetchUserByNodeId("node1")).thenReturn(nodeResponse);
166+
167+
User result = processor.process(user);
168+
169+
assertThat(result).isNotNull();
170+
assertThat(result.getUsername()).isEqualTo("newusername");
171+
assertThat(result.getEmail()).isEqualTo("new@email.com");
172+
assertThat(result.getProfileImage()).isEqualTo("https://new-avatar.png");
173+
assertThat(result.getTotalScore()).isGreaterThan(0);
174+
verify(fullStrategy, times(2)).update(eq(user), any());
175+
verify(gitHubActivityService).fetchUserByNodeId("node1");
176+
}
177+
178+
@Test
179+
@DisplayName("nodeId 조회 결과에 사용자 정보가 없으면 USER_NOT_FOUND 예외를 전파한다")
180+
void should_throwException_when_nodeIdLookupReturnsNoUser() {
181+
User user = createUser();
182+
183+
when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null);
184+
when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any()))
185+
.thenReturn(Optional.empty());
186+
when(fullStrategy.update(eq(user), any()))
187+
.thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND));
188+
189+
GitHubNodeUserResponse emptyResponse = new GitHubNodeUserResponse(
190+
new GitHubNodeUserResponse.Data(null, null)
191+
);
192+
when(gitHubActivityService.fetchUserByNodeId("node1")).thenReturn(emptyResponse);
193+
194+
assertThatThrownBy(() -> processor.process(user))
195+
.isInstanceOf(GitHubApiNonRetryableException.class);
196+
197+
verify(gitHubActivityService).fetchUserByNodeId("node1");
198+
}
199+
200+
@Test
201+
@DisplayName("username 변경 복구 후 재시도에서도 실패하면 예외를 전파한다")
202+
void should_throwException_when_retryAlsoFails() {
203+
User user = createUser();
204+
205+
when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null);
206+
when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any()))
207+
.thenReturn(Optional.empty());
208+
when(fullStrategy.update(eq(user), any()))
209+
.thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND));
210+
211+
GitHubNodeUserResponse nodeResponse = new GitHubNodeUserResponse(
212+
new GitHubNodeUserResponse.Data(
213+
new GitHubNodeUserResponse.Node("node1", "newusername", "new@email.com", "https://new-avatar.png"),
214+
null
215+
)
216+
);
217+
when(gitHubActivityService.fetchUserByNodeId("node1")).thenReturn(nodeResponse);
218+
219+
assertThatThrownBy(() -> processor.process(user))
220+
.isInstanceOf(GitHubApiNonRetryableException.class);
221+
222+
verify(gitHubActivityService).fetchUserByNodeId("node1");
223+
verify(fullStrategy, times(2)).update(eq(user), any());
224+
}
225+
}
135226
}

src/test/java/com/gitranker/api/domain/user/service/UserPersistenceServiceTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,10 @@ void should_saveUser_when_profileUpdated() {
9999
User user = createUser();
100100
when(userRepository.save(user)).thenReturn(user);
101101

102-
User result = userPersistenceService.updateProfile(user, "newname", "https://new.img");
102+
User result = userPersistenceService.updateProfile(user, "newname", "https://new.img", "new@email.com");
103103

104104
assertThat(result.getUsername()).isEqualTo("newname");
105+
assertThat(result.getEmail()).isEqualTo("new@email.com");
105106
verify(userRepository).save(user);
106107
}
107108
}

0 commit comments

Comments
 (0)