diff --git a/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java b/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java index 9e239834..82c8fb8e 100644 --- a/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java +++ b/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java @@ -2,7 +2,6 @@ import com.checkup.checkup.domain.face.entity.FaceTemplate; import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -22,11 +21,30 @@ public interface FaceTemplateRepository extends JpaRepository findForUpdateByStudentId(@Param("studentId") Long studentId); - @EntityGraph(attributePaths = "student") - List findAllByOrderByStudent_IdAsc(); - - @EntityGraph(attributePaths = "student") - List findAllByStudent_IdIn(Collection studentIds); - void deleteByStudent_Id(Long studentId); + + /** + * 출석 대상 학생의 템플릿만 학생 id 순으로 읽는다. 얼굴 인식 세션을 만들 때 쓴다. + * 조건은 {@link com.checkup.checkup.domain.member.entity.Student#isAttendanceEligible()}(동의 2종, 호실)과 + * DataGSM 학생 id가 있는 것이다. 출석 대상 규칙이 바뀌면 이 쿼리와 {@link #findAllEligibleByStudentIdIn}도 같이 바꾼다. + */ + @Query(""" + SELECT template FROM FaceTemplate template JOIN FETCH template.student student + WHERE student.privacyAgreedAt IS NOT NULL AND student.faceAgreedAt IS NOT NULL + AND student.dormitoryRoom IS NOT NULL AND student.datagsmStudentId > 0 + ORDER BY student.id + """) + List findAllEligible(); + + /** + * 주어진 학생 중 출석 대상인 학생의 템플릿만 읽는다. AI 세션을 다시 만들 때 쓴다. + * 조건은 {@link #findAllEligible()}와 같다. + */ + @Query(""" + SELECT template FROM FaceTemplate template JOIN FETCH template.student student + WHERE student.id IN :studentIds + AND student.privacyAgreedAt IS NOT NULL AND student.faceAgreedAt IS NOT NULL + AND student.dormitoryRoom IS NOT NULL AND student.datagsmStudentId > 0 + """) + List findAllEligibleByStudentIdIn(@Param("studentIds") Collection studentIds); } diff --git a/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java b/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java index 628b0e26..1bc27f97 100644 --- a/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java +++ b/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java @@ -433,9 +433,7 @@ private static Long canonicalStudentId(String value) { } private List eligibleTemplates() { - return faceTemplateRepository.findAllByOrderByStudent_IdAsc().stream() - .filter(template -> eligibleStudent(template.getStudent())) - .toList(); + return faceTemplateRepository.findAllEligible(); } private AiFaceSessionRequest sessionRequest(List templates) { @@ -451,11 +449,6 @@ private AiFaceSessionRequest sessionRequest(List templates) { return new AiFaceSessionRequest(model, candidates); } - private static boolean eligibleStudent(Student student) { - return student.isAttendanceEligible() - && student.getDatagsmStudentId() != null && student.getDatagsmStudentId() > 0; - } - private AiFaceSessionRequest candidateRequest(Set studentIds) { return candidateRequestSnapshot(studentIds, null).request(); } @@ -475,9 +468,7 @@ private Map candidateTemplateRevisions(Set studentIds) { } private List candidateTemplates(Set studentIds) { - List templates = faceTemplateRepository.findAllByStudent_IdIn(studentIds).stream() - .filter(template -> eligibleStudent(template.getStudent())) - .toList(); + List templates = faceTemplateRepository.findAllEligibleByStudentIdIn(studentIds); if (templates.size() != studentIds.size() || templates.size() > properties.maxCandidates() || templates.isEmpty()) { throw new CustomException(ErrorCode.FACE_SESSION_NOT_FOUND); } diff --git a/src/test/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepositoryTest.java b/src/test/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepositoryTest.java new file mode 100644 index 00000000..2759aada --- /dev/null +++ b/src/test/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepositoryTest.java @@ -0,0 +1,114 @@ +package com.checkup.checkup.domain.face.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.checkup.checkup.domain.face.ai.AiFaceModel; +import com.checkup.checkup.domain.face.entity.FaceTemplate; +import com.checkup.checkup.domain.member.entity.Student; +import com.checkup.checkup.domain.member.repository.StudentRepository; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * 실제 PostgreSQL에서 얼굴 인식 세션 후보 쿼리가 출석 대상 학생의 템플릿만 읽는지 검증한다(#236). + * 조건은 {@link Student#isAttendanceEligible()}와 DataGSM 학생 id가 있는 것이다. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class FaceTemplateRepositoryTest { + + private static final Instant T0 = Instant.parse("2026-10-13T03:00:00Z"); + private static final AiFaceModel MODEL = new AiFaceModel("model-a", "v1", 256, "l2"); + + @Autowired + private FaceTemplateRepository repository; + + @Autowired + private StudentRepository studentRepository; + + private Long eligibleId; + private Long otherEligibleId; + private Long noConsentId; + private Long noFaceConsentId; + private Long noPrivacyConsentId; + private Long noRoomId; + private Long noDatagsmIdId; + + @BeforeEach + void setUp() { + eligibleId = saveWithTemplate(eligible(94_001L, 301)); + otherEligibleId = saveWithTemplate(eligible(94_002L, 302)); + + noConsentId = saveWithTemplate(Student.createWithoutMember(94_003L, "동의 안 함", 2, 1, 3, 2103, 303)); + + Student noFaceConsent = eligible(94_004L, 304); + ReflectionTestUtils.setField(noFaceConsent, "faceAgreedAt", null); + noFaceConsentId = saveWithTemplate(noFaceConsent); + + Student noPrivacyConsent = eligible(94_005L, 305); + ReflectionTestUtils.setField(noPrivacyConsent, "privacyAgreedAt", null); + noPrivacyConsentId = saveWithTemplate(noPrivacyConsent); + + Student noRoom = eligible(94_006L, 306); + noRoom.leaveDormitory(); + noRoomId = saveWithTemplate(noRoom); + + noDatagsmIdId = saveWithTemplate(eligible(null, 307)); + } + + @Test + @DisplayName("동의 2종·호실·DataGSM 학생 id가 모두 있는 학생의 템플릿만 학생 id 순으로 읽는다") + void findAllEligibleReturnsOnlyAttendanceEligibleStudents() { + List templates = repository.findAllEligible(); + + assertThat(templates).extracting(template -> template.getStudent().getId()) + .containsExactly(eligibleId, otherEligibleId); + } + + @Test + @DisplayName("주어진 학생 중에서도 출석 대상이 아닌 학생의 템플릿은 빠진다") + void findAllEligibleByStudentIdInSkipsIneligibleStudents() { + List requested = List.of(eligibleId, noConsentId, noFaceConsentId, noPrivacyConsentId, + noRoomId, noDatagsmIdId); + + List templates = repository.findAllEligibleByStudentIdIn(requested); + + assertThat(templates).extracting(template -> template.getStudent().getId()) + .containsExactly(eligibleId); + } + + @Test + @DisplayName("쿼리 결과는 Student.isAttendanceEligible()과 같은 판단이다") + void queryMatchesEntityRule() { + List templates = repository.findAllEligible(); + + assertThat(templates).allSatisfy(template -> { + assertThat(template.getStudent().isAttendanceEligible()).isTrue(); + assertThat(template.getStudent().getDatagsmStudentId()).isPositive(); + }); + assertThat(repository.findAll()).filteredOn(template -> template.getStudent().isAttendanceEligible() + && template.getStudent().getDatagsmStudentId() != null + && template.getStudent().getDatagsmStudentId() > 0) + .hasSameSizeAs(templates); + } + + private Student eligible(Long datagsmStudentId, int room) { + Student student = Student.createWithoutMember(datagsmStudentId, "학생" + room, 2, 1, room % 100, 2100 + room % 100, + room); + student.agree(false, T0, "v1"); + return student; + } + + private Long saveWithTemplate(Student student) { + Student saved = studentRepository.save(student); + repository.save(FaceTemplate.create(saved, MODEL, "[[1.0]]")); + return saved.getId(); + } +} diff --git a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java index 800182d5..6f870941 100644 --- a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java +++ b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java @@ -87,7 +87,7 @@ void refreshesActiveSessionWithCurrentCandidates() { FaceTemplate otherTemplate = template(OTHER_STUDENT_DB_ID, OTHER_DATAGSM_STUDENT_ID, vectorJson(1)); given(sessionStore.findWithStudent(STUDENT_DB_ID)).willReturn(List.of(session)); given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true); - given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID))) + given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID))) .willReturn(List.of(refreshedTemplate, otherTemplate)); service.refreshStudentTemplate(STUDENT_DB_ID); @@ -109,7 +109,7 @@ void retriesWhenAnotherCandidateChangesDuringRefresh() { FaceTemplate otherAfter = versionedTemplate(OTHER_STUDENT_DB_ID, OTHER_DATAGSM_STUDENT_ID, 1L, vectorJson(2)); given(sessionStore.findWithStudent(STUDENT_DB_ID)).willReturn(List.of(session)); given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true); - given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID))) + given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID))) .willReturn(List.of(target, otherBefore), List.of(target, otherAfter)); service.refreshStudentTemplate(STUDENT_DB_ID); @@ -128,7 +128,7 @@ void closesSessionWhenTemplateRefreshFails() { FaceTemplate refreshedTemplate = template(STUDENT_DB_ID, DATAGSM_STUDENT_ID, vectorJson(0)); given(sessionStore.findWithStudent(STUDENT_DB_ID)).willReturn(List.of(session)); given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true); - given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID))) + given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID))) .willReturn(List.of(refreshedTemplate)); willThrow(new IllegalStateException("AI unavailable")) .given(aiFaceClient).createSession(eq(SESSION_ID), any()); @@ -256,7 +256,7 @@ void recoveryExtendsFrameLock() throws Exception { given(template.getDimension()).willReturn(256); given(template.getNormalization()).willReturn("l2"); given(template.getVectorsJson()).willReturn(toVectorsJson()); - given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID))).willReturn(List.of(template)); + given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID))).willReturn(List.of(template)); given(aiFaceClient.recognize(eq(SESSION_ID), eq("frame-1"), any(), any())) .willAnswer(invocation -> { clock.advance(Duration.ofSeconds(80)); @@ -291,7 +291,7 @@ void ownershipIsSavedBeforeAiSessionForCleanupRetry() throws Exception { given(template.getDimension()).willReturn(256); given(template.getNormalization()).willReturn("l2"); given(template.getVectorsJson()).willReturn(toVectorsJson()); - given(templateRepository.findAllByOrderByStudent_IdAsc()).willReturn(List.of(template)); + given(templateRepository.findAllEligible()).willReturn(List.of(template)); FaceSessionView pending = session(Set.of(STUDENT_DB_ID)); given(sessionStore.findOwnedIfPresent(any(), eq(ADMIN_ID))).willReturn(java.util.Optional.of(pending)); org.mockito.BDDMockito.willThrow(new AiFaceException(503, "session_create", "unavailable")) @@ -325,7 +325,7 @@ void tooManyCandidatesAreRejectedByConfiguredMaximum() { tools.jackson.databind.json.JsonMapper.builder().build(), clock); FaceTemplate first = eligibleTemplate(1L); FaceTemplate second = eligibleTemplate(2L); - given(templateRepository.findAllByOrderByStudent_IdAsc()).willReturn(List.of(first, second)); + given(templateRepository.findAllEligible()).willReturn(List.of(first, second)); org.assertj.core.api.Assertions.assertThatThrownBy(() -> limitedService.create(ADMIN_ID, com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY)) @@ -337,6 +337,21 @@ void tooManyCandidatesAreRejectedByConfiguredMaximum() { verify(aiFaceClient, never()).createSession(any(), any()); } + @Test + @DisplayName("출석 대상 템플릿이 없으면 세션을 만들지 않고 409 FACE_NO_ENROLLED_STUDENTS다") + void noEligibleTemplateIsRejected() { + given(templateRepository.findAllEligible()).willReturn(List.of()); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.create(ADMIN_ID, + com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY)) + .isInstanceOfSatisfying(com.checkup.checkup.global.exception.CustomException.class, + error -> assertThat(error.getErrorCode()).isEqualTo( + com.checkup.checkup.global.exception.ErrorCode.FACE_NO_ENROLLED_STUDENTS)); + + verify(sessionStore, never()).create(any(), any(), any(), any(), any(), any()); + verify(aiFaceClient, never()).createSession(any(), any()); + } + @Test @DisplayName("후보 상한을 따로 정하지 않으면 기본 200명이다") void defaultMaximumIs200() {