diff --git a/src/main/java/com/checkup/checkup/domain/face/dto/FaceFrameResponse.java b/src/main/java/com/checkup/checkup/domain/face/dto/FaceFrameResponse.java index b7e92283..47461e08 100644 --- a/src/main/java/com/checkup/checkup/domain/face/dto/FaceFrameResponse.java +++ b/src/main/java/com/checkup/checkup/domain/face/dto/FaceFrameResponse.java @@ -22,7 +22,7 @@ public record Face( Recognition recognition, @Schema(description = "이 얼굴 트랙의 연속 인식 시도 횟수(DEC-004)", example = "1") int attempts, - @Schema(description = "true면 QR 출석 안내를 띄운다(3회 초과 실패, DEC-004). OTHER_FLOOR면 항상 false") + @Schema(description = "true면 QR 출석 안내를 띄운다(3회 초과 실패, DEC-004)") boolean qrRecommended ) { } @@ -35,8 +35,8 @@ public record Quality( } public record Recognition( - @Schema(description = "KNOWN, UNKNOWN, NOT_ATTEMPTED, OTHER_FLOOR 중 하나. OTHER_FLOOR는 세션 층이 아닌 학생이고 이름·학번·출석이 없다", - allowableValues = {"KNOWN", "UNKNOWN", "NOT_ATTEMPTED", "OTHER_FLOOR"}) + @Schema(description = "KNOWN, UNKNOWN, NOT_ATTEMPTED 중 하나. 세션 층이 아닌 학생은 UNKNOWN이다", + allowableValues = {"KNOWN", "UNKNOWN", "NOT_ATTEMPTED"}) String status, @Schema(description = "학생 이름. status가 KNOWN일 때만 있다") String studentName, 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 82c8fb8e..ea632719 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 @@ -24,21 +24,23 @@ public interface FaceTemplateRepository extends JpaRepository 0 + WHERE student.dormitoryRoom BETWEEN :firstRoom AND :lastRoom + AND student.privacyAgreedAt IS NOT NULL AND student.faceAgreedAt IS NOT NULL + AND student.datagsmStudentId > 0 ORDER BY student.id """) - List findAllEligible(); + List findAllEligibleInRooms(@Param("firstRoom") int firstRoom, @Param("lastRoom") int lastRoom); /** * 주어진 학생 중 출석 대상인 학생의 템플릿만 읽는다. AI 세션을 다시 만들 때 쓴다. - * 조건은 {@link #findAllEligible()}와 같다. + * 조건은 {@link #findAllEligibleInRooms}와 같다. */ @Query(""" SELECT template FROM FaceTemplate template JOIN FETCH template.student student 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 1d49fad9..108e40bc 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 @@ -48,6 +48,7 @@ public class FaceRecognitionService { private static final int VECTOR_DIMENSION = 256; private static final int MAX_TEMPLATE_REFRESH_ATTEMPTS = 3; private static final Set CAMERA_FLOORS = Set.of(3, 4, 5); + private static final int ROOMS_PER_FLOOR = 100; private final FaceTemplateRepository faceTemplateRepository; private final FaceSessionStore faceSessionStore; @@ -87,8 +88,8 @@ public FaceRecognitionService( } /** - * 관리자 카메라 페이지의 얼굴 인식 세션을 만든다. 출석 대상(필수 동의·호실)이고 얼굴을 등록한 학생이 후보다. - * 후보는 층과 상관없이 전체 출석 대상이다. 다른 층 학생을 이 층 학생으로 잘못 맞히지 않고 {@code OTHER_FLOOR}로 알리기 위해서다(#253). + * 관리자 카메라 페이지의 얼굴 인식 세션을 만든다. 고른 층의 출석 대상(필수 동의·호실)이고 얼굴을 등록한 학생이 후보다. + * 층 학생만 비교해 인식을 빠르게 한다. 다른 층 학생은 후보에 없으므로 인식 실패로 처리된다(#256). * 같은 관리자의 다른 세션과 겹쳐도 거부하지 않는다. 층을 바꾸면 웹이 이전 세션을 닫고 새로 만든다. * * @param adminMemberId 세션의 관리자 회원 id @@ -97,8 +98,8 @@ public FaceRecognitionService( * @return 새 세션 id와 용도·층 * @throws CustomException 층이 없거나 3·4·5가 아니면 {@link ErrorCode#INVALID_FLOOR}(400), * 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403), - * 후보 학생이 없으면 {@link ErrorCode#FACE_NO_ENROLLED_STUDENTS}(409), - * 후보가 설정된 최대(기본 200명)를 넘으면 {@link ErrorCode#FACE_TOO_MANY_CANDIDATES}(422), + * 그 층에 후보 학생이 없으면 {@link ErrorCode#FACE_NO_ENROLLED_STUDENTS}(409), + * 층 후보가 설정된 최대(기본 200명)를 넘으면 {@link ErrorCode#FACE_TOO_MANY_CANDIDATES}(422), * 저장된 템플릿의 모델이 서로 다르거나 벡터가 깨졌으면 {@link ErrorCode#FACE_AI_BAD_GATEWAY}(502). * AI 서버 오류는 {@link com.checkup.checkup.domain.face.ai.AiFaceException}으로 던지고 * {@code GlobalExceptionHandler}가 {@link ErrorCode#FACE_AI_UNAVAILABLE}(503), @@ -109,7 +110,7 @@ public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose, if (floor == null || !CAMERA_FLOORS.contains(floor)) { throw new CustomException(ErrorCode.INVALID_FLOOR); } - List templates = eligibleTemplates(); + List templates = eligibleTemplates(floor); if (templates.isEmpty()) { throw new CustomException(ErrorCode.FACE_NO_ENROLLED_STUDENTS); } @@ -140,7 +141,7 @@ public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose, /** * 카메라 프레임 한 장에서 얼굴을 인식하고, 현재 세션 후보로 확인된 학생을 출석 처리한다. * AI가 후보 밖의 학생을 지목하면 출석으로 인정하지 않고 {@code UNKNOWN}으로 낮춘다. 결과는 최근 인식 기록에도 남긴다(#143). - * 세션 층이 아닌 학생은 이름·학번·출석 없이 {@code OTHER_FLOOR}로 알린다(#253). + * 세션을 만든 뒤 다른 층으로 옮긴 학생도 후보 밖과 같이 {@code UNKNOWN}으로 처리한다(#256). * 프레임 이미지는 처리 뒤 메모리에서 지우고 저장하지 않는다. * * @param frameIdHeader 프레임 id(선택, 최대 128자). 없으면 새로 만든다 @@ -346,13 +347,9 @@ private FaceFrameResponse.Face toPublicFace( : studentRepository.findByDatagsmStudentId(dataGsmStudentId); boolean candidate = student.isPresent() && session.candidateStudentIds().contains(student.get().getId()) - && student.get().isAttendanceEligible(); - if (candidate && isOtherFloor(session, student.get())) { - // 다른 층 학생은 이 카메라 화면에 신원을 보이지 않고 출석·최근 인식에도 남기지 않는다(#253). - // 층을 잘못 찾은 학생에게 QR 안내는 도움이 안 되므로 띄우지 않는다. - status = "OTHER_FLOOR"; - qrRecommended = false; - } else if (candidate) { + && student.get().isAttendanceEligible() + && onSessionFloor(session, student.get()); + if (candidate) { studentName = student.get().getName(); studentNumber = student.get().getStudentNumber(); AttendanceRecordResult recorded = attendanceService.markAttendedFor( @@ -368,7 +365,7 @@ private FaceFrameResponse.Face toPublicFace( } } } else { - // Spring이 관리하는 현재 세션 후보 목록 밖의 학생을 AI가 지목해도 절대 출석으로 인정하지 않는다. + // Spring이 관리하는 현재 세션 후보 목록 밖이거나 다른 층 학생을 AI가 지목해도 절대 출석으로 인정하지 않는다. status = "UNKNOWN"; } } @@ -385,8 +382,8 @@ private FaceFrameResponse.Face toPublicFace( } /** 층 도입 전에 만든 세션(층 null)은 층을 따지지 않는다. */ - private static boolean isOtherFloor(FaceSessionView session, Student student) { - return session.floor() != null && !session.floor().equals(student.getDormitoryFloor()); + private static boolean onSessionFloor(FaceSessionView session, Student student) { + return session.floor() == null || session.floor().equals(student.getDormitoryFloor()); } /** @@ -453,8 +450,9 @@ private static Long canonicalStudentId(String value) { } } - private List eligibleTemplates() { - return faceTemplateRepository.findAllEligible(); + private List eligibleTemplates(int floor) { + return faceTemplateRepository.findAllEligibleInRooms(floor * ROOMS_PER_FLOOR, + floor * ROOMS_PER_FLOOR + ROOMS_PER_FLOOR - 1); } private AiFaceSessionRequest sessionRequest(List templates) { 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 index 2759aada..cebd52c9 100644 --- a/src/test/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepositoryTest.java +++ b/src/test/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepositoryTest.java @@ -65,13 +65,30 @@ void setUp() { @Test @DisplayName("동의 2종·호실·DataGSM 학생 id가 모두 있는 학생의 템플릿만 학생 id 순으로 읽는다") - void findAllEligibleReturnsOnlyAttendanceEligibleStudents() { - List templates = repository.findAllEligible(); + void findAllEligibleInRoomsReturnsOnlyAttendanceEligibleStudents() { + List templates = repository.findAllEligibleInRooms(300, 399); assertThat(templates).extracting(template -> template.getStudent().getId()) .containsExactly(eligibleId, otherEligibleId); } + @Test + @DisplayName("호실 범위로 읽으면 그 범위 층의 출석 대상 학생 템플릿만 읽는다") + void findAllEligibleInRoomsReturnsOnlyStudentsOnFloor() { + Long fourthFloorId = saveWithTemplate(eligible(94_008L, 401)); + Long fifthFloorId = saveWithTemplate(eligible(94_009L, 501)); + + assertThat(repository.findAllEligibleInRooms(300, 399)) + .extracting(template -> template.getStudent().getId()) + .containsExactly(eligibleId, otherEligibleId); + assertThat(repository.findAllEligibleInRooms(400, 499)) + .extracting(template -> template.getStudent().getId()) + .containsExactly(fourthFloorId); + assertThat(repository.findAllEligibleInRooms(500, 599)) + .extracting(template -> template.getStudent().getId()) + .containsExactly(fifthFloorId); + } + @Test @DisplayName("주어진 학생 중에서도 출석 대상이 아닌 학생의 템플릿은 빠진다") void findAllEligibleByStudentIdInSkipsIneligibleStudents() { @@ -87,7 +104,7 @@ void findAllEligibleByStudentIdInSkipsIneligibleStudents() { @Test @DisplayName("쿼리 결과는 Student.isAttendanceEligible()과 같은 판단이다") void queryMatchesEntityRule() { - List templates = repository.findAllEligible(); + List templates = repository.findAllEligibleInRooms(300, 399); assertThat(templates).allSatisfy(template -> { assertThat(template.getStudent().isAttendanceEligible()).isTrue(); 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 0e437bd0..40859092 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 @@ -292,7 +292,7 @@ void ownershipIsSavedBeforeAiSessionForCleanupRetry() throws Exception { given(template.getDimension()).willReturn(256); given(template.getNormalization()).willReturn("l2"); given(template.getVectorsJson()).willReturn(toVectorsJson()); - given(templateRepository.findAllEligible()).willReturn(List.of(template)); + given(templateRepository.findAllEligibleInRooms(300, 399)).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")) @@ -326,7 +326,7 @@ void tooManyCandidatesAreRejectedByConfiguredMaximum() { tools.jackson.databind.json.JsonMapper.builder().build(), clock); FaceTemplate first = eligibleTemplate(1L); FaceTemplate second = eligibleTemplate(2L); - given(templateRepository.findAllEligible()).willReturn(List.of(first, second)); + given(templateRepository.findAllEligibleInRooms(300, 399)).willReturn(List.of(first, second)); org.assertj.core.api.Assertions.assertThatThrownBy(() -> limitedService.create(ADMIN_ID, com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY, 3)) @@ -341,7 +341,7 @@ void tooManyCandidatesAreRejectedByConfiguredMaximum() { @Test @DisplayName("출석 대상 템플릿이 없으면 세션을 만들지 않고 409 FACE_NO_ENROLLED_STUDENTS다") void noEligibleTemplateIsRejected() { - given(templateRepository.findAllEligible()).willReturn(List.of()); + given(templateRepository.findAllEligibleInRooms(300, 399)).willReturn(List.of()); org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.create(ADMIN_ID, com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY, 3)) @@ -371,7 +371,7 @@ void invalidFloorIsRejected(Integer floor) { @DisplayName("세션을 만들면 요청한 층을 저장하고 응답에 돌려준다") void createdSessionKeepsFloor() throws Exception { FaceTemplate template = versionedTemplate(STUDENT_DB_ID, DATAGSM_STUDENT_ID, 0L, vectorJson(0)); - given(templateRepository.findAllEligible()).willReturn(List.of(template)); + given(templateRepository.findAllEligibleInRooms(400, 499)).willReturn(List.of(template)); var response = service.create(ADMIN_ID, AttendancePurpose.DORMITORY, 4); @@ -381,6 +381,22 @@ void createdSessionKeepsFloor() throws Exception { eq(List.of(STUDENT_DB_ID)), any(), any()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"3, 300, 399", "4, 400, 499", "5, 500, 599"}) + @DisplayName("세션 후보는 고른 층 호실 학생의 템플릿만 읽어 AI에 보낸다") + void candidatesAreLimitedToChosenFloor(int floor, int firstRoom, int lastRoom) throws Exception { + FaceTemplate template = versionedTemplate(STUDENT_DB_ID, DATAGSM_STUDENT_ID, 0L, vectorJson(0)); + given(templateRepository.findAllEligibleInRooms(firstRoom, lastRoom)).willReturn(List.of(template)); + + var response = service.create(ADMIN_ID, AttendancePurpose.DORMITORY, floor); + + verify(templateRepository).findAllEligibleInRooms(firstRoom, lastRoom); + ArgumentCaptor request = ArgumentCaptor.forClass(AiFaceSessionRequest.class); + verify(aiFaceClient).createSession(eq(response.sessionId()), request.capture()); + assertThat(request.getValue().candidates()).extracting(AiFaceCandidate::studentId) + .containsExactly(DATAGSM_STUDENT_ID.toString()); + } + @Test @DisplayName("후보 상한을 따로 정하지 않으면 기본 200명이다") void defaultMaximumIs200() { @@ -524,24 +540,39 @@ void logFailureDoesNotBreakRecognition() { } @Test - @DisplayName("세션 층이 아닌 학생은 이름·학번·출석 없이 OTHER_FLOOR이고 QR 안내와 최근 인식을 남기지 않는다") - void otherFloorStudentIsReportedWithoutIdentity() { - givenFrame(new AiFaceFrameResponse("frame-1", List.of(new AiFaceFrameResponse.FaceResult( - "track-1", List.of(0.1, 0.2, 0.3, 0.4), List.of(List.of(0.2, 0.3)), - new AiFaceFrameResponse.Quality(0.8, 0.7, List.of()), - new AiFaceFrameResponse.Recognition("KNOWN", DATAGSM_STUDENT_ID.toString(), 0.91, 0.12), 0, true)))); + @DisplayName("세션 뒤 다른 층으로 옮긴 학생은 이름·학번·출석 없이 UNKNOWN이고 출석 처리하지 않는다") + void studentMovedToOtherFloorIsUnknown() { + givenFrame(frame("KNOWN", DATAGSM_STUDENT_ID.toString())); givenEligibleStudent(); given(eligibleStudent.getDormitoryFloor()).willReturn(5); var face = service.recognize(ADMIN_ID, SESSION_ID, "frame-1", "image/jpeg", new byte[]{1, 2}) .faces().getFirst(); - assertThat(face.recognition().status()).isEqualTo("OTHER_FLOOR"); + assertThat(face.recognition().status()).isEqualTo("UNKNOWN"); assertThat(face.recognition().studentName()).isNull(); assertThat(face.recognition().studentNumber()).isNull(); assertThat(face.recognition().attendance()).isNull(); - assertThat(face.qrRecommended()).isFalse(); - verifyNoInteractions(attendanceService, logService); + verifyNoInteractions(attendanceService); + } + + @Test + @DisplayName("다른 층 학생도 실패가 쌓여 AI가 QR을 안내하면 QR 안내를 그대로 띄우고 실패로 남긴다") + void otherFloorStudentKeepsQrRecommendation() { + FaceSessionView session = givenFrame(new AiFaceFrameResponse("frame-1", List.of(new AiFaceFrameResponse.FaceResult( + "track-1", List.of(0.1, 0.2, 0.3, 0.4), List.of(List.of(0.2, 0.3)), + new AiFaceFrameResponse.Quality(0.8, 0.7, List.of()), + new AiFaceFrameResponse.Recognition("KNOWN", DATAGSM_STUDENT_ID.toString(), 0.91, 0.12), 3, true)))); + givenEligibleStudent(); + given(eligibleStudent.getDormitoryFloor()).willReturn(5); + + var face = service.recognize(ADMIN_ID, SESSION_ID, "frame-1", "image/jpeg", new byte[]{1, 2}) + .faces().getFirst(); + + assertThat(face.recognition().status()).isEqualTo("UNKNOWN"); + assertThat(face.qrRecommended()).isTrue(); + verify(logService).record(session, "track-1", FaceRecognitionResult.FAILED, null, NOW); + verifyNoInteractions(attendanceService); } @Test