Repository: GAMZA-BAT/algohub-server
Pull request #377: fix: prevent solution submission failure for duplicate BJNickname users
Author: login: hwangjokim
Labels: name: bug
description: 버그 수정
Created: 2025-04-23T17:45:48Z | Merged: 2025-04-27T11:00:54Z

## 📌 Related Issue
<!-- issue 링크 -->
close https://linear.app/algo-hub/issue/BE-21/백준-아이디가-중복일-경우-풀이-등록-실패
## 🚀 Description
<!-- 작업 내용 -->
- 같은 백준 아이디 유저 여러명이면 풀이 등록 안되는거 수정
- 코드 리팩토링했음

## 📢 Review Point
<!-- 코드리뷰 할 때 더 집중해서? 봐주면 좋을 포인트들 -->
- 동작 테스트 안해봤어요 (시간없어서..) 

## 📚Etc (선택)
<!-- 작업 도중 생긴 특이사항, 또는 고려할 것들(?) -->


Diff:
diff --git a/src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java b/src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java
index 58b22325..67d6fb6b 100644
--- a/src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java
+++ b/src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java
@@ -13,6 +13,7 @@
 import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup;
 import com.gamzabat.algohub.feature.problem.domain.Problem;
 import com.gamzabat.algohub.feature.problem.repository.querydsl.CustomProblemRepository;
+import com.gamzabat.algohub.feature.user.domain.User;
 
 public interface ProblemRepository extends JpaRepository<Problem, Long>, CustomProblemRepository {
 	@Query("select p from Problem p where p.id = :id and p.deletedAt is null")
@@ -20,8 +21,20 @@ public interface ProblemRepository extends JpaRepository<Problem, Long>, CustomP
 
 	Page<Problem> findAllByStudyGroup(StudyGroup studyGroup, Pageable pageable);
 
-	@Query("select p from Problem p where p.deletedAt is null and p.studyGroup.deletedAt is null and p.number = :number")
-	List<Problem> findAllByNumber(Integer number);
+	@Query("""
+		    SELECT p FROM Problem p
+			JOIN FETCH p.studyGroup
+		    WHERE p.number = :number
+		  	AND p.endDate > :today
+			AND p.deletedAt is null
+		  	AND p.studyGroup.deletedAt is null
+		      AND EXISTS (
+		        SELECT gm FROM GroupMember gm
+		        WHERE gm.user = :user
+		          AND gm.studyGroup = p.studyGroup
+		    )
+		""")
+	List<Problem> findValidProblemsByNumberAndUser(Integer number, LocalDate today, User user);
 
 	@Query("select p from Problem p "
 		+ "where p.deletedAt is null "
diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java b/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java
index b898e5f5..66f1b588 100644
--- a/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java
+++ b/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java
@@ -2,9 +2,7 @@
 
 import java.time.LocalDate;
 import java.time.LocalDateTime;
-import java.util.Iterator;
 import java.util.List;
-import java.util.Optional;
 
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
@@ -194,54 +192,56 @@ private Integer getCorrectCount(Solution solution) {
 	@Transactional
 	public void createSolution(CreateSolutionRequest request) {
 
-		List<Problem> problems = problemRepository.findAllByNumber(request.problemNumber());
-		if (problems.isEmpty()) {
-			throw new ProblemValidationException(HttpStatus.NOT_FOUND.value(), "존재하지 않는 문제 입니다.");
+		List<User> users = userRepository.findAllByBjNickname(request.userName());
+		if (users.isEmpty()) {
+			log.warn("user {} not found for create solution", request.userName());
+			throw new UserValidationException("해당 아이디로 등록된 백준 유저는 없습니다.");
 		}
 
-		User user = userRepository.findByBjNickname(request.userName())
-			.orElseThrow(() -> new UserValidationException("존재하지 않는 유저 입니다."));
-
-		Iterator<Problem> iterator = problems.iterator();
-		while (iterator.hasNext()) {
-			Problem problem = iterator.next();
-			StudyGroup studyGroup = problem.getStudyGroup(); // problem에 딸린 그룹 고유id 로 studyGroup 가져오기
-			Optional<GroupMember> member = groupMemberRepository.findByUserAndStudyGroup(user, studyGroup);
-			LocalDate endDate = problem.getEndDate();
-			LocalDate now = LocalDate.now();
-			LocalDateTime solvedDateTime = LocalDateTime.now();
-			boolean isFirstCorrectSolution = true;
-
-			if (member.isEmpty() || endDate == null || now.isAfter(endDate)) {
-				iterator.remove();
-				continue;
+		final LocalDateTime now = LocalDateTime.now();
+
+		for (User user : users) {
+			List<Problem> problems = problemRepository.findValidProblemsByNumberAndUser(
+				request.problemNumber(), LocalDate.from(now), user);
+
+			if (problems.isEmpty()) {
+				log.warn("problem {} not found for create solution, user : {}", request.problemNumber(), user.getId());
 			}
 
-			if (!isCorrect(request.result()) || solutionRepository.existsByUserAndProblemAndResult(user, problem,
-				BOJResultConstants.CORRECT))
-				isFirstCorrectSolution = false;
-
-			solutionRepository.save(Solution.builder()
-				.problem(problem)
-				.user(user)
-				.content(request.code())
-				.memoryUsage(request.memoryUsage())
-				.executionTime(request.executionTime())
-				.language(request.codeType())
-				.codeLength(request.codeLength())
-				.result(request.result())
-				.solvedDateTime(solvedDateTime)
-				.build()
-			);
-
-			if (isFirstCorrectSolution) {
-				rankingService.updateScore(member.get(), problem.getEndDate(), solvedDateTime);
-				rankingUpdateService.updateRanking(studyGroup);
+			for (Problem problem : problems) {
+				final boolean isFirstCorrect =
+					isCorrect(request.result()) && !solutionRepository.existsByUserAndProblemAndResult(user, problem,
+						BOJResultConstants.CORRECT);
+
+				Long solutionId = solutionRepository.save(Solution.builder()
+					.problem(problem)
+					.user(user)
+					.content(request.code())
+					.memoryUsage(request.memoryUsage())
+					.executionTime(request.executionTime())
+					.language(request.codeType())
+					.codeLength(request.codeLength())
+					.result(request.result())
+					.solvedDateTime(now)
+					.build()
+				).getId();
+
+				GroupMember groupMember = groupMemberRepository.findByUserAndStudyGroup(user, problem.getStudyGroup())
+					.orElseThrow(() -> new IllegalStateException("Logic Error"));
+
+				if (isFirstCorrect) {
+					rankingService.updateScore(groupMember, problem.getEndDate(), now);
+					rankingUpdateService.updateRanking(problem.getStudyGroup());
+				}
+
+				sendNewSolutionNotification(problem.getStudyGroup(), groupMember, problem);
+
+				log.info("success to create solution for user: {}, solutionId: {}", user.getId(), solutionId);
+
 			}
 
-			sendNewSolutionNotification(studyGroup, member.get(), problem);
 		}
-		log.info("success to create solution user_id={}.", user.getId());
+
 	}
 
 	private void sendNewSolutionNotification(StudyGroup group, GroupMember solver, Problem problem) {
diff --git a/src/main/java/com/gamzabat/algohub/feature/user/repository/UserRepository.java b/src/main/java/com/gamzabat/algohub/feature/user/repository/UserRepository.java
index 3f085958..86daa3ab 100644
--- a/src/main/java/com/gamzabat/algohub/feature/user/repository/UserRepository.java
+++ b/src/main/java/com/gamzabat/algohub/feature/user/repository/UserRepository.java
@@ -1,5 +1,6 @@
 package com.gamzabat.algohub.feature.user.repository;
 
+import java.util.List;
 import java.util.Optional;
 
 import org.springframework.data.jpa.repository.JpaRepository;
@@ -20,7 +21,7 @@ public interface UserRepository extends JpaRepository<User, Long> {
 		+ "AND u.deletedAt IS NULL")
 	Optional<User> findByEmail(String email);
 
-	Optional<User> findByBjNickname(String bjNickname);
+	List<User> findAllByBjNickname(String bjNickname);
 
 	boolean existsByNickname(String nickname);
 
diff --git a/src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java b/src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java
index 3cc7efa3..01dd1917 100644
--- a/src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java
+++ b/src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java
@@ -68,7 +68,7 @@ class SolutionServiceTest {
 	private SolutionCommentRepository solutionCommentRepository;
 	@Mock
 	private UserRepository userRepository;
-	private User user, user2;
+	private User user, user2, user3;
 	private Problem problem, problem1, problem2;
 	private StudyGroup group, group1;
 	private Long groupId = 30L;
@@ -83,6 +83,8 @@ void setUp() throws NoSuchFieldException, IllegalAccessException {
 			.role(Role.USER).profileImage("profileImage").build();
 		user2 = User.builder().email("email2").password("password").nickname("nickname2").bjNickname("bjNickname2")
 			.role(Role.USER).profileImage("profileImage").build();
+		user3 = User.builder().email("email3").password("password").nickname("nickname3").bjNickname("bjNickname2")
+			.role(Role.USER).profileImage("profileImage").build();
 		group = StudyGroup.builder().name("name").groupImage("imageUrl").groupCode("code").build();
 		group1 = StudyGroup.builder().name("name1").groupImage("imageUrl1").groupCode("code1").build();
 		problem = Problem.builder()
@@ -114,6 +116,7 @@ void setUp() throws NoSuchFieldException, IllegalAccessException {
 		userField.setAccessible(true);
 		userField.set(user, 1L);
 		userField.set(user2, 2L);
+		userField.set(user3, 3L);
 
 		Field problemField = Problem.class.getDeclaredField("id");
 		problemField.setAccessible(true);
@@ -490,7 +493,7 @@ private void setTestSolutionAndCommentList(List<Solution> list, List<SolutionCom
 	void createSolution() {
 		// given
 		CreateSolutionRequest request = new CreateSolutionRequest(
-			"bjNickname",
+			"bjNickname2",
 			"code",
 			"Java",
 			"result",
@@ -508,6 +511,10 @@ void createSolution() {
 			.studyGroup(group)
 			.user(user2)
 			.build();
+		GroupMember member3 = GroupMember.builder()
+			.studyGroup(group)
+			.user(user3)
+			.build();
 
 		Problem problem = Problem.builder()
 			.number(300)
@@ -515,18 +522,27 @@ void createSolution() {
 			.endDate(LocalDate.now().plusDays(30))
 			.build();
 
-		List<GroupMember> members = List.of(member1, member2);
-		when(problemRepository.findAllByNumber(300)).thenReturn(List.of(problem));
-		when(userRepository.findByBjNickname("bjNickname")).thenReturn(Optional.of(user));
-		when(groupMemberRepository.findByUserAndStudyGroup(user, group)).thenReturn(Optional.of(member1));
+		Solution solutionStub = mock(Solution.class);
+
+		List<GroupMember> members = List.of(member1, member2, member3);
+
+		List<User> sameBojNicknameUsers = List.of(user2, user3);
+		when(problemRepository.findValidProblemsByNumberAndUser(any(), any(LocalDate.class),
+			any(User.class))).thenReturn(List.of(problem));
+		when(userRepository.findAllByBjNickname("bjNickname2")).thenReturn(sameBojNicknameUsers);
+		when(solutionRepository.save(any())).thenReturn(solutionStub);
+		when(solutionStub.getId()).thenReturn(123L);
+		when(groupMemberRepository.findByUserAndStudyGroup(user2, group)).thenReturn(Optional.of(member2));
+		when(groupMemberRepository.findByUserAndStudyGroup(user3, group)).thenReturn(Optional.of(member3));
+
 		when(groupMemberRepository.findAllByStudyGroup(group)).thenReturn(members);
 
 		// when
 		solutionService.createSolution(request);
 
 		// then
-		verify(solutionRepository, times(1)).save(any(Solution.class));
-		verify(notificationService, times(1)).sendNotificationToMembers(any(), any(), any(), any(), any(), any());
+		verify(solutionRepository, times(2)).save(any(Solution.class));
+		verify(notificationService, times(2)).sendNotificationToMembers(any(), any(), any(), any(), any(), any());
 	}
 
 	@Test


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR aims to fix the solution submission failure when multiple users share the same BJNickname by refactoring how solutions are created and validated.  
- Updated test cases to include an additional user scenario and adjusted verifications accordingly.  
- Modified the UserRepository method to return all users with a given BJNickname.  
- Refactored the createSolution logic in the SolutionService to iterate over all matching users and updated the ProblemRepository query to filter valid problems.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

| File | Description |
| ---- | ----------- |
| src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java | Added user3 to test duplicate BJNickname handling and updated verifications for solution saves and notifications. |
| src/main/java/com/gamzabat/algohub/feature/user/repository/UserRepository.java | Changed method signature to return a list of users for a given BJNickname. |
| src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java | Refactored createSolution to process multiple users and updated ranking and notification calls accordingly. |
| src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java | Replaced the old query with a new one that filters problems based on endDate and user group membership. |




state: COMMENTED
author_association: NONE
submitted_at: 2025-04-23T17:46:27Z
user: login: hwangjokim
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T17:47:11Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR addresses the issue where solution submission fails when multiple users share the same BJNickname by refactoring the solution creation logic and updating related test and repository methods.  
- Updated tests to include a duplicate BJNickname scenario and verify that notifications and solution saves occur for each matching user.  
- Modified UserRepository to return a List of users instead of a single Optional, supporting multiple users with the same BJNickname.  
- Refactored SolutionService to iterate over all matching users and updated the JPQL query in ProblemRepository to correctly fetch valid problems.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

| File | Description |
| ---- | ----------- |
| src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java | Added a new user (user3) to simulate duplicate BJNickname scenarios and updated verification counts. |
| src/main/java/com/gamzabat/algohub/feature/user/repository/UserRepository.java | Changed the return type of findByBjNickname from Optional to List. |
| src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java | Refactored solution creation logic to process each user with the provided BJNickname and updated related notifications and ranking updates. |
| src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java | Updated the JPQL query to fetch valid problems based on problem end date and group membership. |


<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java:86**
* [nitpick] The duplicate 'bjNickname2' for user3 (same as user2) might be confusing; add an inline comment to clarify that this is intentional for testing duplicate BJNickname scenarios.
```
user3 = User.builder().email("email3").password("password").nickname("nickname3").bjNickname("bjNickname2")
```
**src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java:203**
* [nitpick] Consider renaming the loop variable (e.g., to currentUser) to avoid shadowing and ensure clarity when reading logs and tracking user-specific operations.
```
for (User user : users) {
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-04-23T17:54:19Z
user: login: rladmstn
body: LGTM 머지할게요
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T11:00:29Z

Review comments:
diff_hunk: @@ -13,15 +13,26 @@
 import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup;
 import com.gamzabat.algohub.feature.problem.domain.Problem;
 import com.gamzabat.algohub.feature.problem.repository.querydsl.CustomProblemRepository;
+import com.gamzabat.algohub.feature.user.domain.User;
 
 public interface ProblemRepository extends JpaRepository<Problem, Long>, CustomProblemRepository {
 	@Query("select p from Problem p where p.id = :id and p.deletedAt is null")
 	Optional<Problem> findById(Long id);
 
 	Page<Problem> findAllByStudyGroup(StudyGroup studyGroup, Pageable pageable);
 
-	@Query("select p from Problem p where p.deletedAt is null and p.studyGroup.deletedAt is null and p.number = :number")
-	List<Problem> findAllByNumber(Integer number);
+	@Query("""
+		    SELECT p FROM Problem p
path: src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java
user: login: Copilot
body: The new query omits filtering for deleted problems and study groups (i.e. conditions for p.deletedAt and p.studyGroup.deletedAt). If these checks are required to ensure only active records are processed, please add them back.
created_at: 2025-04-23T17:46:27Z
author_association: NONE
diff_hunk: @@ -13,15 +13,26 @@
 import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup;
 import com.gamzabat.algohub.feature.problem.domain.Problem;
 import com.gamzabat.algohub.feature.problem.repository.querydsl.CustomProblemRepository;
+import com.gamzabat.algohub.feature.user.domain.User;
 
 public interface ProblemRepository extends JpaRepository<Problem, Long>, CustomProblemRepository {
 	@Query("select p from Problem p where p.id = :id and p.deletedAt is null")
 	Optional<Problem> findById(Long id);
 
 	Page<Problem> findAllByStudyGroup(StudyGroup studyGroup, Pageable pageable);
 
-	@Query("select p from Problem p where p.deletedAt is null and p.studyGroup.deletedAt is null and p.number = :number")
-	List<Problem> findAllByNumber(Integer number);
+	@Query("""
+		    SELECT p FROM Problem p
path: src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java
user: login: hwangjokim
body: 오 신기능이라 넣어봤는데 재밌네요.. 
그리고 맞는 리뷰 했어요 ㅋㅋㅋ 

created_at: 2025-04-23T17:47:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,54 +192,56 @@ private Integer getCorrectCount(Solution solution) {
 	@Transactional
 	public void createSolution(CreateSolutionRequest request) {
 
-		List<Problem> problems = problemRepository.findAllByNumber(request.problemNumber());
-		if (problems.isEmpty()) {
-			throw new ProblemValidationException(HttpStatus.NOT_FOUND.value(), "존재하지 않는 문제 입니다.");
+		List<User> users = userRepository.findAllByBjNickname(request.userName());
+		if (users.isEmpty()) {
+			log.warn("user {} not found for create solution", request.userName());
+			throw new UserValidationException("해당 아이디로 등록된 백준 유저는 없습니다.");
 		}
path: src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java
user: login: rladmstn
body: 중요한 사항은 아니긴 한데, 이 부분은 꼭 예외를 던져야 할까요?
created_at: 2025-04-27T10:55:15Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-04-23T17:45:50Z
body: <p><a href="https://linear.app/algo-hub/issue/BE-21/백준-아이디가-중복일-경우-풀이-등록-실패">BE-21 백준 아이디가 중복일 경우 풀이 등록 실패</a></p>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-23T17:47:57Z
body: ## Test Results
 18 files   18 suites   6s ⏱️
306 tests 306 ✅ 0 💤 0 ❌
308 runs  308 ✅ 0 💤 0 ❌

Results for commit b6dfabb9.

[test-results]:data:application/gzip;base64,H4sIACgpCWgC/13MSw6CMBSF4a2Qjh20BcqtmzF9JjcCNX2MjHu3IqHo8PtPcp7E4+wSuXYMLh1JBXOTLVFlDGu1qKxL/mw9PXRLxZj/dMdHTfQIXuH8E1yMIe4llvX7CTvaZSvtcfPpcPP5z4RlwVxBtLBeaS210XbqmeVO8l7IkcFgFVA/wDRSDkBeb1300MwFAQAA

:recycle: This comment has been updated with latest results.
author_association: NONE
user: login: hwangjokim
created_at: 2025-04-23T17:55:16Z
body: `Comments suppressed due to low confidence (2)`
칸에 있는걸 읽어보니, 그닥인거같기도 하고요. 컨텍스트 파악하는 능력은 별로네요 
author_association: CONTRIBUTOR