From 46573390eba89850176ab69c1bef963e4a1c70ef Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 27 Oct 2026 18:18:51 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=ED=98=B8=EC=8B=A4=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=B6=9C=EC=84=9D=20=EB=8C=80=EC=83=81=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../face/repository/FaceTemplateRepository.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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..7aa99796 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 @@ -36,6 +36,19 @@ public interface FaceTemplateRepository extends JpaRepository findAllEligible(); + /** + * 호실 번호가 {@code firstRoom}~{@code lastRoom}인 출석 대상 학생의 템플릿만 학생 id 순으로 읽는다. + * 얼굴 인식 세션을 만들 때 고른 층 학생만 후보로 쓰려고 부른다(#256). 출석 대상 조건은 {@link #findAllEligible()}와 같다. + */ + @Query(""" + SELECT template FROM FaceTemplate template JOIN FETCH template.student student + 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 findAllEligibleInRooms(@Param("firstRoom") int firstRoom, @Param("lastRoom") int lastRoom); + /** * 주어진 학생 중 출석 대상인 학생의 템플릿만 읽는다. AI 세션을 다시 만들 때 쓴다. * 조건은 {@link #findAllEligible()}와 같다. From ced6a4780bb3d08aedbd5b0e6ad469d6f8b51148 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 27 Oct 2026 18:20:37 +0900 Subject: [PATCH 2/8] =?UTF-8?q?test:=20=ED=98=B8=EC=8B=A4=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EA=B0=80=20=EA=B7=B8=20=EC=B8=B5=20=ED=95=99=EC=83=9D=EB=A7=8C?= =?UTF-8?q?=20=EC=9D=BD=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FaceTemplateRepositoryTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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..45f6571a 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 @@ -72,6 +72,23 @@ void findAllEligibleReturnsOnlyAttendanceEligibleStudents() { .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() { From b3342f758a6a282dec89ea2825b562549339333c Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 27 Oct 2026 18:21:09 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=96=BC=EA=B5=B4=20=EC=9D=B8?= =?UTF-8?q?=EC=8B=9D=20=EC=84=B8=EC=85=98=20=ED=9B=84=EB=B3=B4=EB=A5=BC=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=EC=B8=B5=20=ED=95=99=EC=83=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../face/service/FaceRecognitionService.java | 16 +++++++++------- .../face/service/FaceRecognitionServiceTest.java | 8 ++++---- 2 files changed, 13 insertions(+), 11 deletions(-) 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..10082e0b 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); } @@ -453,8 +454,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/service/FaceRecognitionServiceTest.java b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java index 0e437bd0..af9510d2 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); From 759097c71f38e1f6255d55ccc6bcffa4ab645aa2 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 27 Oct 2026 18:21:32 +0900 Subject: [PATCH 4/8] =?UTF-8?q?test:=20=EC=B8=B5=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EA=B7=B8=20=EC=B8=B5=20=ED=98=B8=EC=8B=A4=20=ED=95=99=EC=83=9D?= =?UTF-8?q?=EB=A7=8C=20=ED=9B=84=EB=B3=B4=EB=A1=9C=20=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FaceRecognitionServiceTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 af9510d2..59f47ab6 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 @@ -381,6 +381,23 @@ 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); + verify(templateRepository, never()).findAllEligible(); + 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() { From 12f476250f50218d9572df893a8d66377d070036 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 27 Oct 2026 18:22:18 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=EC=93=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EC=A0=84=20=EC=B8=B5=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../face/repository/FaceTemplateRepository.java | 17 +++-------------- .../repository/FaceTemplateRepositoryTest.java | 6 +++--- .../service/FaceRecognitionServiceTest.java | 1 - 3 files changed, 6 insertions(+), 18 deletions(-) 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 7aa99796..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,22 +24,11 @@ public interface FaceTemplateRepository extends JpaRepository 0 - ORDER BY student.id - """) - List findAllEligible(); - - /** - * 호실 번호가 {@code firstRoom}~{@code lastRoom}인 출석 대상 학생의 템플릿만 학생 id 순으로 읽는다. - * 얼굴 인식 세션을 만들 때 고른 층 학생만 후보로 쓰려고 부른다(#256). 출석 대상 조건은 {@link #findAllEligible()}와 같다. - */ @Query(""" SELECT template FROM FaceTemplate template JOIN FETCH template.student student WHERE student.dormitoryRoom BETWEEN :firstRoom AND :lastRoom @@ -51,7 +40,7 @@ public interface FaceTemplateRepository extends JpaRepository templates = repository.findAllEligible(); + void findAllEligibleInRoomsReturnsOnlyAttendanceEligibleStudents() { + List templates = repository.findAllEligibleInRooms(300, 399); assertThat(templates).extracting(template -> template.getStudent().getId()) .containsExactly(eligibleId, otherEligibleId); @@ -104,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 59f47ab6..3a9a4364 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 @@ -391,7 +391,6 @@ void candidatesAreLimitedToChosenFloor(int floor, int firstRoom, int lastRoom) t var response = service.create(ADMIN_ID, AttendancePurpose.DORMITORY, floor); verify(templateRepository).findAllEligibleInRooms(firstRoom, lastRoom); - verify(templateRepository, never()).findAllEligible(); ArgumentCaptor request = ArgumentCaptor.forClass(AiFaceSessionRequest.class); verify(aiFaceClient).createSession(eq(response.sessionId()), request.capture()); assertThat(request.getValue().candidates()).extracting(AiFaceCandidate::studentId) From 30750895017400c5122476b1eb1684014f9884f0 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 27 Oct 2026 18:23:07 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=EB=8B=A4=EB=A5=B8=20=EC=B8=B5=20?= =?UTF-8?q?=ED=95=99=EC=83=9D=20OTHER=5FFLOOR=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20UNKNOWN=20=EC=8B=A4=ED=8C=A8=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EA=BE=B8=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../face/service/FaceRecognitionService.java | 18 +++++++----------- .../service/FaceRecognitionServiceTest.java | 14 +++++--------- 2 files changed, 12 insertions(+), 20 deletions(-) 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 10082e0b..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 @@ -141,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자). 없으면 새로 만든다 @@ -347,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( @@ -369,7 +365,7 @@ private FaceFrameResponse.Face toPublicFace( } } } else { - // Spring이 관리하는 현재 세션 후보 목록 밖의 학생을 AI가 지목해도 절대 출석으로 인정하지 않는다. + // Spring이 관리하는 현재 세션 후보 목록 밖이거나 다른 층 학생을 AI가 지목해도 절대 출석으로 인정하지 않는다. status = "UNKNOWN"; } } @@ -386,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()); } /** 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 3a9a4364..e617e57c 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 @@ -540,24 +540,20 @@ 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 From dbb37089b4e790523fe325ebad9514166a0e0472 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 27 Oct 2026 18:23:07 +0900 Subject: [PATCH 7/8] =?UTF-8?q?test:=20=EB=8B=A4=EB=A5=B8=20=EC=B8=B5=20?= =?UTF-8?q?=ED=95=99=EC=83=9D=EB=8F=84=20QR=20=EC=95=88=EB=82=B4=EA=B0=80?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80=EB=90=98=EB=8A=94=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FaceRecognitionServiceTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 e617e57c..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 @@ -556,6 +556,25 @@ void studentMovedToOtherFloorIsUnknown() { 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 @DisplayName("층 도입 전에 만든 세션은 층을 따지지 않고 KNOWN으로 출석 처리한다") void sessionWithoutFloorIgnoresStudentFloor() { From 65a0e545d65c49a976cb374241d0490aacfcd2d9 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 27 Oct 2026 18:23:20 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20=EC=96=BC=EA=B5=B4=20=EC=9D=B8?= =?UTF-8?q?=EC=8B=9D=20=EC=9D=91=EB=8B=B5=20swagger=EC=97=90=EC=84=9C=20OT?= =?UTF-8?q?HER=5FFLOOR=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../checkup/checkup/domain/face/dto/FaceFrameResponse.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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,