Skip to content

Commit 08c12c2

Browse files
authored
(#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 방지
1 parent 38056f2 commit 08c12c2

11 files changed

Lines changed: 250 additions & 69 deletions

File tree

.github/workflows/opencode.yml

Lines changed: 0 additions & 44 deletions
This file was deleted.

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));

0 commit comments

Comments
 (0)