From a5dc2ba13cb2718b6df99b0a76d9b4383bf5bc52 Mon Sep 17 00:00:00 2001 From: Subin Cho Date: Mon, 21 Apr 2025 17:38:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=A6=B0?= =?UTF-8?q?=ED=8A=B8=EB=B3=84=20=EC=B0=B8=EA=B0=80=EC=9E=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20&=20=EB=8F=99=EB=A3=8C=20=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EB=B0=98=ED=99=98=20=EB=A9=94=EC=86=8C?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback/api/FeedbackController.java | 16 ++++ .../feedback/application/FeedbackService.java | 15 ++++ .../dao/FeedbackRepositoryCustom.java | 8 ++ .../feedback/dao/FeedbackRepositoryImpl.java | 46 +++++++++- ...rojectParticipantFeedbackInfoResponse.java | 13 +++ .../application/FeedbackServiceTest.java | 85 +++++++++++++++++++ 6 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java diff --git a/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java b/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java index d97624b7..39d82947 100644 --- a/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java +++ b/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java @@ -5,6 +5,7 @@ import com.amcamp.domain.feedback.dto.request.OriginalFeedbackRequest; import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; import com.amcamp.domain.feedback.dto.response.FeedbackRefineResponse; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -22,6 +23,21 @@ public class FeedbackController { private final FeedbackService feedbackService; + @Operation(summary = "스프린트별 동료평가 여부 확인", description = "스프린트별/팀원별 동료평가 여부를 확인합니다. ") + @GetMapping("/{sprintId}") + public Slice feedbackStatusFind( + @PathVariable Long sprintId, + @Parameter(description = "프로젝트 아이디") @RequestParam Long projectId, + @Parameter(description = "이전 페이지의 마지막 프로젝트 참가자 ID (첫 페이지는 비워두세요)") + @RequestParam(required = false) + Long lastProjectParticipantId, + @Parameter(description = "페이지당 프로젝트 참여자 수", example = "1") @RequestParam(value = "size") + int pageSize) { + + return feedbackService.findFeedbackStatusBySprint( + projectId, sprintId, lastProjectParticipantId, pageSize); + } + @Operation( summary = "OpenAI 기반 피드백 메시지 개선", description = "사용자가 입력한 피드백을 AI가 분석하여 부드럽고 명확하게 개선합니다.") diff --git a/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java b/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java index ad027c5b..b8e42edc 100644 --- a/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java +++ b/src/main/java/com/amcamp/domain/feedback/application/FeedbackService.java @@ -12,6 +12,7 @@ import com.amcamp.domain.project.domain.Project; import com.amcamp.domain.project.domain.ProjectParticipant; import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import com.amcamp.domain.sprint.dao.SprintRepository; import com.amcamp.domain.sprint.domain.Sprint; import com.amcamp.domain.team.dao.TeamParticipantRepository; @@ -81,6 +82,20 @@ public Slice findSprintFeedbacksByParticipant( projectParticipant.getId(), sprintId, lastFeedbackId, pageSize); } + @Transactional(readOnly = true) + public Slice findFeedbackStatusBySprint( + Long projectId, Long sprintId, Long lastProjectParticipantId, int pageSize) { + final Member currentMember = memberUtil.getCurrentMember(); + final Project project = findByProjectId(projectId); + final Sprint sprint = findBySprintId(sprintId); + + ProjectParticipant projectParticipant = validateProjectParticipant(currentMember, project); + validateProjectSprintMismatch(project, sprint); + + return feedbackRepository.findSprintFeedbackStatusByParticipant( + projectParticipant, sprintId, lastProjectParticipantId, pageSize); + } + private Project findByProjectId(Long projectId) { return projectRepository .findById(projectId) diff --git a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java index ddae7097..bf9d5af8 100644 --- a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java +++ b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryCustom.java @@ -1,9 +1,17 @@ package com.amcamp.domain.feedback.dao; import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import org.springframework.data.domain.Slice; public interface FeedbackRepositoryCustom { Slice findSprintFeedbacksByParticipant( Long projectParticipantId, Long sprintId, Long lastFeedbackId, int pageSize); + + Slice findSprintFeedbackStatusByParticipant( + ProjectParticipant projectParticipant, + Long sprintId, + Long lastProjectParticipantId, + int pageSize); } diff --git a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java index 7529523c..0abc9032 100644 --- a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java +++ b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java @@ -3,10 +3,14 @@ import static com.amcamp.domain.feedback.domain.QFeedback.feedback; import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; +import com.amcamp.domain.project.domain.ProjectParticipant; +import com.amcamp.domain.project.domain.QProjectParticipant; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import com.amcamp.global.exception.CommonException; import com.amcamp.global.exception.errorcode.FeedbackErrorCode; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -48,6 +52,45 @@ public Slice findSprintFeedbacksByParticipant( return checkLastPage(pageSize, results); } + @Override + public Slice findSprintFeedbackStatusByParticipant( + ProjectParticipant sender, Long sprintId, Long lastProjectParticipantId, int pageSize) { + QProjectParticipant receiver = QProjectParticipant.projectParticipant; + + // 1. sender가 이 sprint에서 피드백한 receiver ID 목록 먼저 뽑기 + List feedbackGivenReceiverIds = + jpaQueryFactory + .select(feedback.receiver.id) + .from(feedback) + .where(feedback.sender.eq(sender), feedback.sprint.id.eq(sprintId)) + .fetch(); + + // 2. 프로젝트 참여자 목록 뽑고, 해당 ID가 feedbackGivenReceiverIds 안에 있는지 체크해서 status 판단 + List results = + jpaQueryFactory + .select( + Projections.constructor( + ProjectParticipantFeedbackInfoResponse.class, + receiver.id, + receiver.teamParticipant.member.nickname, + receiver.teamParticipant.member.profileImageUrl, + receiver.projectRole, + receiver.status, + new CaseBuilder() + .when(receiver.id.in(feedbackGivenReceiverIds)) + .then("COMPLETED") + .otherwise("PENDING"))) + .from(receiver) + .where( + receiver.project.eq(sender.getProject()), + receiver.id.gt(lastProjectParticipantId)) + .orderBy(receiver.id.asc()) + .limit(pageSize + 1) + .fetch(); + + return checkLastPage(pageSize, results); + } + private BooleanExpression lastFeedbackId(Long feedbackId) { if (feedbackId == null) { return null; @@ -56,8 +99,7 @@ private BooleanExpression lastFeedbackId(Long feedbackId) { return feedback.id.lt(feedbackId); } - private Slice checkLastPage( - int pageSize, List results) { + private Slice checkLastPage(int pageSize, List results) { boolean hasNext = false; if (results.size() > pageSize) { diff --git a/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java b/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java new file mode 100644 index 00000000..b52b25bf --- /dev/null +++ b/src/main/java/com/amcamp/domain/project/dto/response/ProjectParticipantFeedbackInfoResponse.java @@ -0,0 +1,13 @@ +package com.amcamp.domain.project.dto.response; + +import com.amcamp.domain.project.domain.ProjectParticipantRole; +import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ProjectParticipantFeedbackInfoResponse( + @Schema(description = "프로젝트 참여자 아이디", example = "1") Long projectParticipantId, + @Schema(description = "프로젝트 참여자 이름", example = "정선우") String nickname, + @Schema(description = "프로젝트 참여자 이미지 url", example = "PreSigned URL") String profileImageUrl, + @Schema(description = "프로젝트 참여자 권한", example = "PROJECT_ADMIN") ProjectParticipantRole role, + @Schema(description = "프로젝트 참여자 참여 상태", example = "ACTIVE") ProjectParticipantStatus status, + @Schema(description = "동료평가 완료 여부", example = "COMPLETED") String feedbackStatus) {} diff --git a/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java b/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java index c7d8fc50..72678705 100644 --- a/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java +++ b/src/test/java/com/amcamp/domain/feedback/application/FeedbackServiceTest.java @@ -17,6 +17,7 @@ import com.amcamp.domain.project.domain.ProjectParticipant; import com.amcamp.domain.project.domain.ProjectParticipantRole; import com.amcamp.domain.project.domain.ProjectParticipantStatus; +import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import com.amcamp.domain.sprint.dao.SprintRepository; import com.amcamp.domain.sprint.domain.Sprint; import com.amcamp.domain.team.dao.TeamParticipantRepository; @@ -57,6 +58,7 @@ public class FeedbackServiceTest extends IntegrationTest { private ProjectParticipant anotherSender; private ProjectParticipant receiver; private ProjectParticipant anotherReceiver; + private ProjectParticipant notReceiver; private ProjectParticipant unknownReceiver; private Sprint sprint; private Sprint anotherSprint; @@ -78,6 +80,13 @@ void setUp() { "testReceiverProfileImageUrl", OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + Member notReceiverMember = + memberRepository.save( + Member.createMember( + "testNotReceiverNickname", + "testNotReceiverProfileImageUrl", + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"))); + // 초기에 로그인한 사용자를 sender로 설정 setAuthenticatedUser(senderMember); @@ -92,6 +101,11 @@ void setUp() { TeamParticipant.createParticipant( receiverMember, team, TeamParticipantRole.USER)); + TeamParticipant teamParticipantUserNotReceiver = + teamParticipantRepository.save( + TeamParticipant.createParticipant( + notReceiverMember, team, TeamParticipantRole.USER)); + project = projectRepository.save( Project.createProject( @@ -110,6 +124,12 @@ void setUp() { projectParticipantRepository.save( ProjectParticipant.createProjectParticipant( teamParticipantUser, project, ProjectParticipantRole.MEMBER)); + notReceiver = + projectParticipantRepository.save( + ProjectParticipant.createProjectParticipant( + teamParticipantUserNotReceiver, + project, + ProjectParticipantRole.MEMBER)); anotherReceiver = projectParticipantRepository.save( @@ -348,6 +368,71 @@ class 피드백_메시지를_조회할_때 { } } + @Nested + class 스프린트별_동료평가_여부를_확인할_때 { + @Test + void 스프린트가_존재하지않으면_예외가_발생한다() { + // when & then + assertThatThrownBy( + () -> + feedbackService.findFeedbackStatusBySprint( + project.getId(), 999L, null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(SprintErrorCode.SPRINT_NOT_FOUND.getMessage()); + } + + @Test + void 요청한_프로젝트가_로그인된_사용자가_참여한_프로젝트가_아니라면_예외가_발생한다() { + // given + // sender는 project(1)에만 참여 중이고, + // receiver는 project(1)와 anotherProject(2) 둘 다 참여 중이므로 + // 검증을 명확히 하기 위해 로그인한 사용자를 sender로 변경 + setAuthenticatedUser(sender.getTeamParticipant().getMember()); + + // when & then + assertThatThrownBy( + () -> + feedbackService.findFeedbackStatusBySprint( + anotherProject.getId(), sprint.getId(), null, 1)) + .isInstanceOf(CommonException.class) + .hasMessage(ProjectErrorCode.PROJECT_PARTICIPATION_REQUIRED.getMessage()); + } + + @Test + void 팀원정보와_동료평가여부를_반환한다() { + feedbackRepository.save(Feedback.createFeedback(sender, receiver, sprint, "수고하셨습니다!")); + // when: sender 기준, 해당 스프린트에서의 팀원 평가 상태 조회 + Slice result = + feedbackService.findFeedbackStatusBySprint( + project.getId(), sprint.getId(), 0L, 10); + + // then + assertThat(result.getContent().size()) + .isEqualTo( + 3); // receiver 1명만 ACTIVE 상태로 있는 프로젝트 참가자, 본인과 피드백을 받지 않은 사람은 PENDING + + // (1) 동료평가를 받은 사람은 COMPLETED + ProjectParticipantFeedbackInfoResponse received_response = + result.getContent().stream() + .filter(r -> r.projectParticipantId().equals(receiver.getId())) + .findFirst() + .orElseThrow(() -> new AssertionError("receiver에 대한 응답이 존재하지 않음")); + + assertThat(received_response.nickname()).isEqualTo("testReceiverNickname"); + assertThat(received_response.feedbackStatus()).isEqualTo("COMPLETED"); + + // (1) 동료평가를 받지 사람 & sender는 PENDING + ProjectParticipantFeedbackInfoResponse sender_response = + result.getContent().stream() + .filter(r -> r.projectParticipantId().equals(sender.getId())) + .findFirst() + .orElseThrow(() -> new AssertionError("sender에 대한 응답이 존재하지 않음")); + + assertThat(sender_response.nickname()).isEqualTo("testSenderNickname"); + assertThat(sender_response.feedbackStatus()).isEqualTo("PENDING"); + } + } + private void setAuthenticatedUser(Member member) { UserDetails userDetails = new PrincipalDetails(member.getId(), member.getRole()); UsernamePasswordAuthenticationToken token = From e93ecf939ec9d8ec25b005cc9c59ed992e4ea84d Mon Sep 17 00:00:00 2001 From: Subin Cho Date: Mon, 21 Apr 2025 19:27:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=B9=88=20result=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback/api/FeedbackController.java | 4 +- .../feedback/dao/FeedbackRepositoryImpl.java | 42 +++++++++++++------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java b/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java index 39d82947..5d26e42f 100644 --- a/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java +++ b/src/main/java/com/amcamp/domain/feedback/api/FeedbackController.java @@ -24,14 +24,14 @@ public class FeedbackController { private final FeedbackService feedbackService; @Operation(summary = "스프린트별 동료평가 여부 확인", description = "스프린트별/팀원별 동료평가 여부를 확인합니다. ") - @GetMapping("/{sprintId}") + @GetMapping("/{sprintId}/participants") public Slice feedbackStatusFind( @PathVariable Long sprintId, @Parameter(description = "프로젝트 아이디") @RequestParam Long projectId, @Parameter(description = "이전 페이지의 마지막 프로젝트 참가자 ID (첫 페이지는 비워두세요)") @RequestParam(required = false) Long lastProjectParticipantId, - @Parameter(description = "페이지당 프로젝트 참여자 수", example = "1") @RequestParam(value = "size") + @Parameter(description = "페이지당 프로젝트 참여자 수", example = "5") @RequestParam(value = "size") int pageSize) { return feedbackService.findFeedbackStatusBySprint( diff --git a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java index 0abc9032..83b56849 100644 --- a/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java +++ b/src/main/java/com/amcamp/domain/feedback/dao/FeedbackRepositoryImpl.java @@ -1,13 +1,16 @@ package com.amcamp.domain.feedback.dao; import static com.amcamp.domain.feedback.domain.QFeedback.feedback; +import static com.amcamp.domain.member.domain.QMember.member; +import static com.amcamp.domain.project.domain.QProjectParticipant.projectParticipant; +import static com.amcamp.domain.team.domain.QTeamParticipant.teamParticipant; import com.amcamp.domain.feedback.dto.response.FeedbackInfoResponse; import com.amcamp.domain.project.domain.ProjectParticipant; -import com.amcamp.domain.project.domain.QProjectParticipant; import com.amcamp.domain.project.dto.response.ProjectParticipantFeedbackInfoResponse; import com.amcamp.global.exception.CommonException; import com.amcamp.global.exception.errorcode.FeedbackErrorCode; +import com.amcamp.global.exception.errorcode.ProjectErrorCode; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.CaseBuilder; @@ -55,7 +58,6 @@ public Slice findSprintFeedbacksByParticipant( @Override public Slice findSprintFeedbackStatusByParticipant( ProjectParticipant sender, Long sprintId, Long lastProjectParticipantId, int pageSize) { - QProjectParticipant receiver = QProjectParticipant.projectParticipant; // 1. sender가 이 sprint에서 피드백한 receiver ID 목록 먼저 뽑기 List feedbackGivenReceiverIds = @@ -71,23 +73,31 @@ public Slice findSprintFeedbackStatusByP .select( Projections.constructor( ProjectParticipantFeedbackInfoResponse.class, - receiver.id, - receiver.teamParticipant.member.nickname, - receiver.teamParticipant.member.profileImageUrl, - receiver.projectRole, - receiver.status, + projectParticipant.id, + member.nickname, + member.profileImageUrl, + projectParticipant.projectRole, + projectParticipant.status, new CaseBuilder() - .when(receiver.id.in(feedbackGivenReceiverIds)) + .when( + projectParticipant.id.in( + feedbackGivenReceiverIds)) .then("COMPLETED") .otherwise("PENDING"))) - .from(receiver) + .from(projectParticipant) + .leftJoin(projectParticipant.teamParticipant, teamParticipant) + .leftJoin(teamParticipant.member, member) .where( - receiver.project.eq(sender.getProject()), - receiver.id.gt(lastProjectParticipantId)) - .orderBy(receiver.id.asc()) + projectParticipant.project.eq(sender.getProject()), + lastProjectParticipantId(lastProjectParticipantId)) + .orderBy(projectParticipant.id.asc()) .limit(pageSize + 1) .fetch(); + if (results.isEmpty()) { + throw new CommonException(ProjectErrorCode.PROJECT_PARTICIPANT_NOT_EXISTS); + } + return checkLastPage(pageSize, results); } @@ -99,6 +109,14 @@ private BooleanExpression lastFeedbackId(Long feedbackId) { return feedback.id.lt(feedbackId); } + private BooleanExpression lastProjectParticipantId(Long projectParticipantId) { + if (projectParticipantId == null) { + return null; + } + + return projectParticipant.id.gt(projectParticipantId); + } + private Slice checkLastPage(int pageSize, List results) { boolean hasNext = false;