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 @@ -36,7 +36,7 @@ public CodeReviewResponse analyzeCode(CodeReviewRequest request) {
.retrieve()
.body(CodeReviewResponse.class);
} catch (Exception e) {
log.warn("Failed to analyze code: {}. Using fallback.", e.getMessage());
log.warn("Failed to analyze code at {}/review: {}. Using fallback.", baseUrl, e.getMessage());
return CodeReviewResponse.builder()
.summary("AI 서버 연결에 실패했습니다. (임시 응답)")
.complexity(CodeReviewResponse.ComplexityInfo.builder()
Expand Down Expand Up @@ -64,7 +64,8 @@ public LearningPathResponse generateLearningPath(LearningPathRequest request) {
.retrieve()
.body(LearningPathResponse.class);
} catch (Exception e) {
log.warn("Failed to generate learning path: {}. Using fallback.", e.getMessage());
log.warn("Failed to generate learning path at {}/learning-path: {}. Using fallback.", baseUrl,
e.getMessage());
return LearningPathResponse.builder()
.analysisSummary("AI 서버 연결 실패")
.personalizedAdvice("현재 AI 분석을 이용할 수 없습니다.")
Expand All @@ -90,7 +91,7 @@ public CodingStyleResponse analyzeCodingStyle(CodingStyleRequest request) {
.retrieve()
.body(CodingStyleResponse.class);
} catch (Exception e) {
log.warn("Failed to analyze coding style: {}. Using fallback.", e.getMessage());
log.warn("Failed to analyze coding style at {}/coding-style: {}. Using fallback.", baseUrl, e.getMessage());
return CodingStyleResponse.builder()
.mbtiCode("NONE")
.nickname("연결되지 않은 코더")
Expand All @@ -111,7 +112,8 @@ public AiCounterExampleResponse generateCounterExample(AiCounterExampleRequest r
.retrieve()
.body(AiCounterExampleResponse.class);
} catch (Exception e) {
log.warn("Failed to generate counter example: {}. Using fallback.", e.getMessage());
log.warn("Failed to generate counter example at {}/debug/counter-example: {}. Using fallback.", baseUrl,
e.getMessage());
return new AiCounterExampleResponse(
"Error",
"Error",
Expand All @@ -132,7 +134,7 @@ public AiSimulatorResponse simulate(AiSimulatorRequest request) {
.retrieve()
.body(AiSimulatorResponse.class);
} catch (Exception e) {
log.warn("Failed to simulate code: {}. Using fallback.", e.getMessage());
log.warn("Failed to simulate code at {}/simulator/run: {}. Using fallback.", baseUrl, e.getMessage());
return AiSimulatorResponse.builder()
.stdout("")
.stderr("AI 서버 연결 실패: " + e.getMessage())
Expand All @@ -155,7 +157,7 @@ public HintChatResponse hintChat(HintChatRequest request) {
.retrieve()
.body(HintChatResponse.class);
} catch (Exception e) {
log.warn("Failed to hint chat: {}. Using fallback.", e.getMessage());
log.warn("Failed to hint chat at {}/tutor/chat: {}. Using fallback.", baseUrl, e.getMessage());
return HintChatResponse.builder()
.reply("죄송해요, AI 튜터가 잠시 자리를 비웠어요. 다시 시도해주세요.")
.teachingStyle("socratic")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,38 @@ public int fetchTop100AverageLevel(String handle) {
}
}

/**
* 사용자 티어 및 기본 스탯 경량 동기화 (Lazy Sync용)
* Bio 인증 없이 API 정보만 가져와서 갱신
*/
@Transactional
public void updateTierAndStats(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));

String handle = user.getSolvedacHandle();
if (handle == null || handle.isBlank()) {
return;
}

try {
SolvedacUser userInfo = solvedacClient.getUserInfo(handle);

// 티어, 레이팅, 클래스, 푼 문제 수 등 갱신
user.updateSolvedacProfile(
handle,
userInfo.tier(),
userInfo.rating(),
userInfo.classLevel(),
userInfo.solvedCount());

userRepository.update(user);

log.info("Lazy synced Solved.ac tier for user {}: Tier {}", userId, userInfo.tier());
} catch (Exception e) {
log.warn("Failed to lazy sync Solved.ac tiers for user {}: {}", userId, e.getMessage());
// 예외를 던지지 않고 로그만 남겨서 프로필 조회 자체는 성공하게 함
}
Comment on lines +215 to +218
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While swallowing exceptions here is intentional to prevent profile lookup failures, it could mask legitimate issues. The log message only captures e.getMessage(), which may not provide enough context for debugging.

Consider:

  1. Logging the full stack trace using log.warn("Failed to lazy sync...", e) instead of just the message
  2. Adding metrics/monitoring for failed sync attempts to track if there's a systemic issue with the Solved.ac API

Copilot uses AI. Check for mistakes.
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ public RestClient restClient(RestClient.Builder builder) {
.connectTimeout(java.time.Duration.ofSeconds(10))
.build();

var requestFactory = new org.springframework.http.client.JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(java.time.Duration.ofSeconds(60));
Comment on lines +24 to +25
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While setting a read timeout helps prevent indefinite blocking on slow responses, this alone doesn't fully prevent Head-of-Line blocking in HTTP/1.1. The issue occurs when a single slow request blocks the entire connection, preventing subsequent requests from being processed.

To truly address Head-of-Line blocking, consider:

  1. Using HTTP/2 multiplexing (though you've explicitly disabled it for FastAPI compatibility)
  2. Implementing connection pooling with multiple concurrent connections
  3. Setting a connection pool size on the HttpClient (e.g., using .executor() to control thread pool size)

The current fix (adding a read timeout) will at least prevent indefinite hangs, which is an improvement, but may not fully resolve the blocking issue described in the PR.

Copilot uses AI. Check for mistakes.

return builder
.requestFactory(new org.springframework.http.client.JdkClientHttpRequestFactory(httpClient))
.requestFactory(requestFactory)
.defaultHeader("Accept", "application/json")
.defaultHeader("Content-Type", "application/json")
.messageConverters(converters -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,53 +111,69 @@ public Integer verifyDefense(Long userId, Integer solvedProblemId) {
}

if (user.getDefenseProblemId().equals(solvedProblemId)) {
// 실제 정답 제출이 있는지 확인 (runtime_ms, memory_kb가 있는 기록)
// 실제 정답 제출이 있는지 확인 (runtime_ms >= 0, memory_kb >= 0 인 기록)
// -1 등은 실패/오류로 간주하여 제외
if (!algorithmRecordRepository.existsSuccessfulSubmission(userId, String.valueOf(solvedProblemId))) {
return null; // 성공적인 제출 기록이 없음 (오답 또는 컴파일 에러)
return null;
}

Integer currentStreak = 0;

// 성공! 풀이 시간 계산 및 저장
algorithmRecordRepository.findLatestSuccessfulByUserAndProblem(userId, String.valueOf(solvedProblemId))
.ifPresent(record -> {
LocalDateTime startTime = user.getDefenseStartTime();
LocalDateTime endTime = record.getCreatedAt() != null ? record.getCreatedAt()
: LocalDateTime.now();
long elapsedSeconds = java.time.Duration.between(startTime, endTime).getSeconds();
record.setElapsedTimeSeconds(elapsedSeconds);
algorithmRecordRepository.update(record);
});

if ("GOLD".equals(user.getDefenseType())) {
user.setGoldStreak(user.getGoldStreak() + 1);
currentStreak = user.getGoldStreak();
if (user.getGoldStreak() > user.getMaxGoldStreak()) {
user.setMaxGoldStreak(user.getGoldStreak());
// 가장 최근의 성공 기록을 가져와서, 그 기록이 *현재 디펜스 시작 이후*에 생성된 것인지 확인
var latestRecordOpt = algorithmRecordRepository.findLatestSuccessfulByUserAndProblem(userId,
String.valueOf(solvedProblemId));

if (latestRecordOpt.isPresent()) {
var record = latestRecordOpt.get();

// CRITICAL: 과거에 푼 기록이 아니라, "지금" 푼 기록이어야 함
if (record.getCreatedAt().isBefore(user.getDefenseStartTime())) {
return null;
}
} else {
user.setSilverStreak(user.getSilverStreak() + 1);
currentStreak = user.getSilverStreak();
if (user.getSilverStreak() > user.getMaxSilverStreak()) {
user.setMaxSilverStreak(user.getSilverStreak());

// CRITICAL: 런타임/메모리가 유효한 값이어야 함 (0ms 포함, -1 제외)
if (record.getRuntimeMs() < 0 || record.getMemoryKb() < 0) {
Comment on lines +135 to +136
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "-1 등은 실패/오류로 간주하여 제외" (treats -1 and similar as failures/errors), but the actual validation checks for < 0. This means the code would accept 0ms runtime or 0KB memory as valid.

While 0ms runtime is theoretically possible for very simple problems (or due to measurement granularity), 0KB memory is physically impossible for any program execution. Consider:

  1. If 0KB memory should be considered invalid, change the check to <= 0 for memoryKb
  2. If 0KB is acceptable, update the comment to clarify that only negative values are rejected, not zero
  3. Consider if 0 values indicate a parsing/measurement error that should also be filtered out
Suggested change
// CRITICAL: 런타임/메모리가 유효한 값이어야 함 (0ms 포함, -1 제외)
if (record.getRuntimeMs() < 0 || record.getMemoryKb() < 0) {
// CRITICAL: 런타임/메모리가 유효한 값이어야 함 (런타임은 0ms 포함, 메모리는 0KB 이상 불가, -1 등 오류 값 제외)
if (record.getRuntimeMs() < 0 || record.getMemoryKb() <= 0) {

Copilot uses AI. Check for mistakes.
return null;
}
}

// 다음 디펜스를 위해 현재 상태 초기화
user.setDefenseProblemId(null);
user.setDefenseStartTime(null);
user.setDefenseType(null);
LocalDateTime startTime = user.getDefenseStartTime();
LocalDateTime endTime = record.getCreatedAt() != null ? record.getCreatedAt()
: LocalDateTime.now();
Comment on lines +141 to +142
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This null check creates inconsistent behavior. If record.getCreatedAt() is null (which shouldn't happen in normal operation since created_at has a DEFAULT CURRENT_TIMESTAMP), the elapsed time is calculated from defense start to "now" instead of to the actual submission time. However, a few lines above (line 131), the code checks if the record was created before the defense start time, which would fail if createdAt is null since null.isBefore() would throw a NullPointerException.

Either:

  1. Remove the null check here since line 131 already implies createdAt is not null
  2. Add an explicit null check before line 131 and return null if createdAt is null

Copilot uses AI. Check for mistakes.
long elapsedSeconds = java.time.Duration.between(startTime, endTime).getSeconds();
record.setElapsedTimeSeconds(elapsedSeconds);
algorithmRecordRepository.update(record);

if ("GOLD".equalsIgnoreCase(user.getDefenseType())) {
user.setGoldStreak(user.getGoldStreak() + 1);
currentStreak = user.getGoldStreak();
if (user.getGoldStreak() > user.getMaxGoldStreak()) {
user.setMaxGoldStreak(user.getGoldStreak());
}
} else {
user.setSilverStreak(user.getSilverStreak() + 1);
currentStreak = user.getSilverStreak();
if (user.getSilverStreak() > user.getMaxSilverStreak()) {
user.setMaxSilverStreak(user.getSilverStreak());
}
}

userRepository.update(user);
return currentStreak;
// 다음 디펜스를 위해 현재 상태 초기화
user.setDefenseProblemId(null);
user.setDefenseStartTime(null);
user.setDefenseType(null);

userRepository.update(user);
return currentStreak;
}
Comment on lines +127 to +168
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic now properly checks that records are created after defense start time and have valid runtime/memory values. However, there's a subtle issue: if existsSuccessfulSubmission returns true (line 116) but findLatestSuccessfulByUserAndProblem returns empty (line 124-127), the method returns null without explanation.

This could happen if:

  1. A record matching the criteria exists but was deleted between the two queries
  2. There's a race condition where another thread deletes/modifies the record

Consider adding logging when latestRecordOpt.isEmpty() to help diagnose why validation failed despite a successful submission existing.

Copilot uses AI. Check for mistakes.
}
return null;
}

private void checkTimeout(User user) {
if (user.getDefenseStartTime() != null) {
if (user.getDefenseStartTime().plusHours(1).isBefore(LocalDateTime.now())) {
if ("GOLD".equals(user.getDefenseType())) {
if ("GOLD".equalsIgnoreCase(user.getDefenseType())) {
user.setGoldStreak(0);
} else {
user.setSilverStreak(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.ssafy.dash.study.application.StudyService;
import com.ssafy.dash.onboarding.domain.OnboardingRepository;
import com.ssafy.dash.user.domain.exception.UserNotFoundException;
import com.ssafy.dash.analytics.application.SolvedacSyncService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -26,17 +27,20 @@ public class UserService {
private final StudyRepository studyRepository;
private final StudyService studyService;
private final LearningPathCacheMapper learningPathCacheMapper;
private final SolvedacSyncService solvedacSyncService;

public UserService(UserRepository userRepository,
OnboardingRepository onboardingRepository,
StudyRepository studyRepository,
StudyService studyService,
LearningPathCacheMapper learningPathCacheMapper) {
LearningPathCacheMapper learningPathCacheMapper,
SolvedacSyncService solvedacSyncService) {
this.userRepository = userRepository;
this.onboardingRepository = onboardingRepository;
this.studyRepository = studyRepository;
this.studyService = studyService;
this.learningPathCacheMapper = learningPathCacheMapper;
this.solvedacSyncService = solvedacSyncService;
Comment on lines 32 to +43
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UserService constructor now requires a SolvedacSyncService parameter, but the existing UserServiceTest class doesn't include a mock for it. This will cause the existing tests to fail at runtime when @InjectMocks tries to construct UserService.

Add a mock field for SolvedacSyncService to the test class:

@Mock
private SolvedacSyncService solvedacSyncService;

Additionally, consider adding test cases for the new lazy sync behavior to ensure:

  1. Sync is triggered when statsLastSyncedAt is null
  2. Sync is triggered when more than 60 minutes have passed
  3. Sync is not triggered when less than 60 minutes have passed
  4. Sync failures don't break the findById operation

Copilot uses AI. Check for mistakes.
}

@Transactional
Expand Down Expand Up @@ -72,6 +76,12 @@ public UserResult findById(Long id) {
// 분석 데이터 존재 여부 확인
boolean hasAnalysis = learningPathCacheMapper.findByUserId(id) != null;

// [Lazy Sync] Solved.ac 연동 정보가 있고, 마지막 동기화 후 1시간이 지났으면 갱신
if (shouldUpdateSolvedacStats(u)) {
solvedacSyncService.updateTierAndStats(id);
u = userRepository.findById(id).orElse(u);
Comment on lines +81 to +82
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a potential race condition here. If two requests call findById simultaneously after the 1-hour threshold has passed, both will trigger updateTierAndStats, resulting in duplicate API calls to Solved.ac. While this won't cause data corruption (the last write wins), it wastes API quota and resources.

Consider one of these approaches:

  1. Add a distributed lock (e.g., Redis-based) around the sync operation keyed by userId
  2. Move the sync to a background job scheduler that runs periodically
  3. Check statsLastSyncedAt again after acquiring the transaction to ensure another thread hasn't already updated it
Suggested change
solvedacSyncService.updateTierAndStats(id);
u = userRepository.findById(id).orElse(u);
// 잠재적 동시성 문제를 줄이기 위해 최신 사용자 정보를 한 번 더 조회하여 조건을 재확인
User latestUser = userRepository.findById(id).orElse(u);
if (shouldUpdateSolvedacStats(latestUser)) {
solvedacSyncService.updateTierAndStats(id);
u = userRepository.findById(id).orElse(u);
}

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +82
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user entity is refetched from the database after the sync operation, which is good. However, this refetch happens outside the transaction boundary of updateTierAndStats. If another operation modifies the user between the sync and this refetch, those changes could be lost or inconsistent data could be returned.

Consider either:

  1. Making the entire block (lines 79-83) run within a single transaction
  2. Returning the updated user from updateTierAndStats to avoid the second database call

Copilot uses AI. Check for mistakes.
}

return UserResult.from(u, onboarding, study, pendingStudyName, hasAnalysis);
}

Expand Down Expand Up @@ -140,4 +150,13 @@ public void blockUser(Long id) {
userRepository.update(u);
}

private boolean shouldUpdateSolvedacStats(User user) {
if (user.getSolvedacHandle() == null || user.getSolvedacHandle().isBlank()) {
return false;
}
// 1시간(60분)이 지났으면 갱신
return user.getStatsLastSyncedAt() == null ||
user.getStatsLastSyncedAt().isBefore(LocalDateTime.now().minusMinutes(60));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
INSERT INTO decorations (name, description, css_class, type, price, is_active) VALUES
('Contributor''s Insight', 'DashHub 발전에 기여해주신 분들을 위한 특별한 선물입니다.', 'effect-contributor', 'SPECIAL', 0, true);
Comment on lines +1 to +2
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single quote in "Contributor's Insight" is properly escaped using double single quotes (''), which is correct SQL syntax. However, this migration doesn't grant the decoration to any users.

Consider documenting how administrators should grant this decoration to contributors, or add a separate migration/script for assigning this decoration to specific users by inserting into the user_decorations table.

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,15 @@

<select id="countSuccessfulSubmissionByUserIdAndProblemNumber" resultType="int"> SELECT COUNT(*)
FROM algorithm_records WHERE user_id = #{userId} AND record_type = 'USER_SOLUTION' AND
problem_number = #{problemNumber} AND runtime_ms IS NOT NULL AND memory_kb IS NOT NULL </select>
problem_number = #{problemNumber} AND runtime_ms >= 0 AND memory_kb >= 0 </select>

<select id="selectSolvedProblemNumbersByUserId" resultType="string" parameterType="long"> SELECT
DISTINCT problem_number FROM algorithm_records WHERE user_id = #{userId} AND (record_type =
'SOLVED_AC_SYNC' OR (runtime_ms IS NOT NULL AND memory_kb IS NOT NULL)) </select>
'SOLVED_AC_SYNC' OR (runtime_ms >= 0 AND memory_kb >= 0)) </select>
Comment on lines +80 to +84
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change from IS NOT NULL to >= 0 is a significant behavioral change. The old query would consider a record with runtime_ms = -1 and memory_kb = -1 as unsuccessful (NULL check fails), while the new query explicitly filters out negative values.

However, this creates a potential edge case: what about NULL values? The new query runtime_ms >= 0 will exclude NULL values (since NULL comparisons return NULL/false in SQL), which maintains backward compatibility. But it's worth documenting this behavior explicitly.

Additionally, verify that all code paths that set these values use -1 (or other negative values) to indicate failure, and never use NULL. Otherwise, records with NULL values might be unintentionally excluded from valid solved problems.

Copilot uses AI. Check for mistakes.

<select id="selectLatestSuccessfulByUserAndProblem" resultType="AlgorithmRecord"> SELECT * FROM
algorithm_records WHERE user_id = #{userId} AND problem_number = #{problemNumber} AND runtime_ms
IS NOT NULL AND memory_kb IS NOT NULL ORDER BY created_at DESC LIMIT 1 </select>
>= 0 AND memory_kb >= 0 ORDER BY created_at DESC LIMIT 1 </select>

<update id="migrateStudyId"> UPDATE algorithm_records SET study_id = #{newStudyId} WHERE study_id
= #{oldStudyId} </update>
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/assets/css/effects.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@
}
}

/* Contributor's Insight (Special Reward) */
.effect-contributor {
background: linear-gradient(90deg, #7c3aed, #22d3ee, #7c3aed);
background-size: 200% auto;
color: transparent;
-webkit-background-clip: text;
background-clip: text;
animation: shine 4s linear infinite;
font-weight: 800;
}

/* --- Tier System (Unified Pure Colors) --- */
/* All tiers use simple colors without shadows/glows */

Expand Down
Loading