diff --git a/build.gradle b/build.gradle index 4de3cb7..1305889 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } -group = 'org.festiamte' +group = 'org.festimate' version = '0.0.1-SNAPSHOT' java { @@ -58,8 +58,26 @@ dependencies { testImplementation 'io.projectreactor:reactor-test' // WebFlux 테스트 지원 testImplementation 'org.springframework.security:spring-security-test' // Security 테스트 지원 testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // JUnit 테스트 실행기 + + // --- QueryDSL --- + implementation "com.querydsl:querydsl-jpa:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + + // --- Jakarta Persistence API --- + implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0' +} + +sourceSets { + main { + java { + srcDir 'src/main/java' + // Q-타입 생성 위치를 IDE가 인식하도록 추가 + srcDir 'build/generated/sources/annotationProcessor/java/main' + } + } } tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepository.java b/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepository.java index f09239a..bc3f451 100644 --- a/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepository.java +++ b/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepository.java @@ -12,34 +12,7 @@ import java.util.List; import java.util.Optional; -public interface MatchingRepository extends JpaRepository { - @Query(""" - SELECT p FROM Participant p - JOIN FETCH p.user - LEFT JOIN Matching m1 - ON (m1.applicantParticipant = p OR m1.targetParticipant = p) - WHERE p.festival.festivalId = :festivalId - AND p.typeResult = :typeResult - AND p.user.gender != :gender - AND p.participantId != :participantId - AND p.participantId NOT IN ( - SELECT m.targetParticipant.participantId - FROM Matching m - WHERE m.applicantParticipant.participantId = :participantId - AND m.status = 'COMPLETED' - ) - GROUP BY p - ORDER BY COUNT(m1) ASC - """) - List findMatchingCandidates( - @Param("participantId") Long participantId, - @Param("typeResult") TypeResult typeResult, - @Param("gender") Gender gender, - @Param("festivalId") Long festivalId, - Pageable pageable - ); - - +public interface MatchingRepository extends JpaRepository, MatchingRepositoryCustom { @Query(""" SELECT m FROM Matching m WHERE m.festival.festivalId = :festivalId diff --git a/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepositoryCustom.java b/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepositoryCustom.java new file mode 100644 index 0000000..d9a0a87 --- /dev/null +++ b/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepositoryCustom.java @@ -0,0 +1,21 @@ +package org.festimate.team.domain.matching.repository; + +import org.festimate.team.domain.participant.entity.Participant; +import org.festimate.team.domain.participant.entity.TypeResult; +import org.festimate.team.domain.user.entity.Gender; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface MatchingRepositoryCustom { + List findExcludeIds(Long applicantId); + + List findMatchingCandidatesDsl( + Long applicantId, + TypeResult typeResult, + Gender gender, + Long festivalId, + Pageable pageable, + List excludedIds + ); +} diff --git a/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepositoryImpl.java b/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepositoryImpl.java new file mode 100644 index 0000000..c1d7df8 --- /dev/null +++ b/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepositoryImpl.java @@ -0,0 +1,65 @@ +package org.festimate.team.domain.matching.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.festimate.team.domain.matching.entity.MatchingStatus; +import org.festimate.team.domain.matching.entity.QMatching; +import org.festimate.team.domain.participant.entity.Participant; +import org.festimate.team.domain.participant.entity.QParticipant; +import org.festimate.team.domain.participant.entity.TypeResult; +import org.festimate.team.domain.user.entity.Gender; +import org.festimate.team.domain.user.entity.QUser; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@RequiredArgsConstructor +public class MatchingRepositoryImpl implements MatchingRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + private final QParticipant p = QParticipant.participant; + private final QMatching m = QMatching.matching; + private final QMatching mSub = new QMatching("mSub"); + private final QUser u = QUser.user; + + @Override + public List findExcludeIds(Long applicantId) { + return queryFactory + .select(mSub.targetParticipant.participantId) + .from(mSub) + .where( + mSub.applicantParticipant.participantId.eq(applicantId) + .and(mSub.status.eq(MatchingStatus.COMPLETED)) + ) + .fetch(); + } + + @Override + public List findMatchingCandidatesDsl( + Long applicantId, + TypeResult typeResult, + Gender gender, + Long festivalId, + Pageable pageable, + List excludedIds + ) { + return queryFactory + .selectFrom(p) + .join(p.user, u).fetchJoin() + .leftJoin(m) + .on(m.applicantParticipant.eq(p).or(m.targetParticipant.eq(p))) + .where( + p.festival.festivalId.eq(festivalId), + p.typeResult.eq(typeResult), + u.gender.ne(gender), + p.participantId.ne(applicantId), + excludedIds.isEmpty() ? null : p.participantId.notIn(excludedIds) + ) + .groupBy(p) + .orderBy(m.count().asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } +} diff --git a/src/main/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImpl.java b/src/main/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImpl.java index e3ea178..77da1ba 100644 --- a/src/main/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImpl.java +++ b/src/main/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImpl.java @@ -90,16 +90,18 @@ protected Matching saveMatching(Festival festival, Optional targetP @Transactional @Override public Optional findBestCandidateByPriority(long festivalId, Participant participant) { + List excludedIds = matchingRepository.findExcludeIds(participant.getParticipantId()); List priorities = MATCHING_PRIORITIES.get(participant.getTypeResult()); Gender myGender = participant.getUser().getGender(); for (TypeResult priorityType : priorities) { - Optional candidate = matchingRepository.findMatchingCandidates( + Optional candidate = matchingRepository.findMatchingCandidatesDsl( participant.getParticipantId(), priorityType, myGender, festivalId, - PageRequest.of(0, 1) + PageRequest.of(0, 1), + excludedIds ).stream().findFirst(); if (candidate.isPresent()) { diff --git a/src/main/java/org/festimate/team/infra/config/QuerydslConfig.java b/src/main/java/org/festimate/team/infra/config/QuerydslConfig.java new file mode 100644 index 0000000..a038a97 --- /dev/null +++ b/src/main/java/org/festimate/team/infra/config/QuerydslConfig.java @@ -0,0 +1,14 @@ +package org.festimate.team.infra.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager em) { + return new JPAQueryFactory(em); + } +} \ No newline at end of file diff --git a/src/test/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImplTest.java b/src/test/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImplTest.java index 1eb4b7c..9233e38 100644 --- a/src/test/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImplTest.java +++ b/src/test/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImplTest.java @@ -116,6 +116,7 @@ void findBestCandidateByPriority_success() { .festival(festival) .build(); ReflectionTestUtils.setField(applicant, "participantId", 1L); + List excludedIds = matchingRepository.findExcludeIds(applicant.getParticipantId()); Participant target = Participant.builder() .user(targetUser) @@ -123,7 +124,7 @@ void findBestCandidateByPriority_success() { .festival(festival) .build(); - when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(List.of(target)); var result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); @@ -145,9 +146,10 @@ void findBestCandidate_prioritySecond_success() { .festival(festival) .build(); ReflectionTestUtils.setField(applicant, "participantId", 1L); + List excludedIds = matchingRepository.findExcludeIds(applicant.getParticipantId()); // 1순위 PHOTO에 대상 없음 - when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(Collections.emptyList()); // 2순위 INFLUENCER에 대상 있음 @@ -157,7 +159,7 @@ void findBestCandidate_prioritySecond_success() { .festival(festival) .build(); - when(matchingRepository.findMatchingCandidates(1L, TypeResult.INFLUENCER, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.INFLUENCER, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(List.of(secondPriorityCandidate)); var result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); @@ -176,16 +178,18 @@ void findBestCandidate_priorityThird_success() { // 신청자 Participant applicant = mockParticipant(applicantUser, festival, TypeResult.INFLUENCER, 1L); + List excludedIds = matchingRepository.findExcludeIds(applicant.getParticipantId()); + // 3순위 NEWBIE에 대상 있음 Participant thirdPriorityCandidate = mockParticipant(thirdPriorityUser, festival, TypeResult.NEWBIE, 2L); // 1순위 PHOTO, 2순위 INFLUENCER 대상 없음 - when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(Collections.emptyList()); - when(matchingRepository.findMatchingCandidates(1L, TypeResult.INFLUENCER, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.INFLUENCER, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(Collections.emptyList()); // 3순위 대상 있음 - when(matchingRepository.findMatchingCandidates(1L, TypeResult.NEWBIE, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.NEWBIE, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(List.of(thirdPriorityCandidate)); var result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); @@ -206,13 +210,14 @@ void findBestCandidate_alreadyMatchedCandidate_empty() { .festival(festival) .build(); ReflectionTestUtils.setField(applicant, "participantId", 1L); + List excludedIds = matchingRepository.findExcludeIds(applicant.getParticipantId()); // 대상이 존재하나 이미 매칭됨 (Repository에서 필터링 됨) - when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(Collections.emptyList()); - when(matchingRepository.findMatchingCandidates(1L, TypeResult.INFLUENCER, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.INFLUENCER, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(Collections.emptyList()); - when(matchingRepository.findMatchingCandidates(1L, TypeResult.NEWBIE, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.NEWBIE, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(Collections.emptyList()); Optional result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); @@ -231,9 +236,10 @@ void findBestCandidateByPriority_empty() { .typeResult(TypeResult.INFLUENCER) .festival(festival) .build(); + List excludedIds = matchingRepository.findExcludeIds(applicant.getParticipantId()); - when(matchingRepository.findMatchingCandidates( - applicant.getParticipantId(), TypeResult.PHOTO, Gender.MAN, festival.getFestivalId(), PageRequest.of(0, 1) + when(matchingRepository.findMatchingCandidatesDsl( + applicant.getParticipantId(), TypeResult.PHOTO, Gender.MAN, festival.getFestivalId(), PageRequest.of(0, 1), excludedIds )).thenReturn(Collections.emptyList()); Optional result = matchingService.findBestCandidateByPriority( @@ -369,6 +375,7 @@ void findBestCandidate_multipleCandidates_returnsFirstOnly() { User applicantUser = mockUser("신청자", Gender.MAN, 1L); Festival festival = mockFestival(applicantUser, 1L, LocalDate.now().minusDays(1), LocalDate.now().plusDays(1)); Participant applicant = mockParticipant(applicantUser, festival, TypeResult.INFLUENCER, 1L); + List excludedIds = matchingRepository.findExcludeIds(applicant.getParticipantId()); User candidate1 = mockUser("후보1", Gender.WOMAN, 2L); User candidate2 = mockUser("후보2", Gender.WOMAN, 3L); @@ -376,7 +383,7 @@ void findBestCandidate_multipleCandidates_returnsFirstOnly() { Participant p2 = mockParticipant(candidate2, festival, TypeResult.PHOTO, 3L); // 후보 2명 반환 - when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + when(matchingRepository.findMatchingCandidatesDsl(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1), excludedIds)) .thenReturn(List.of(p1, p2)); // when