diff --git a/src/main/java/com/checkup/checkup/domain/face/controller/FaceRecognitionController.java b/src/main/java/com/checkup/checkup/domain/face/controller/FaceRecognitionController.java index 65287475..f87bfcdf 100644 --- a/src/main/java/com/checkup/checkup/domain/face/controller/FaceRecognitionController.java +++ b/src/main/java/com/checkup/checkup/domain/face/controller/FaceRecognitionController.java @@ -32,14 +32,14 @@ public class FaceRecognitionController { private final FaceRecognitionService faceRecognitionService; /** 관리자 카메라 페이지마다 새 얼굴 인식 세션을 만든다. */ - @Operation(summary = "얼굴 인식 세션 생성", description = "카메라 페이지마다 새 세션을 만든다. 성공 201.") + @Operation(summary = "얼굴 인식 세션 생성", description = "카메라 페이지·층마다 새 세션을 만든다. floor는 3, 4, 5 중 하나이고 다른 값이면 400 INVALID_FLOOR다. 성공 201.") @PostMapping @ResponseStatus(HttpStatus.CREATED) public FaceSessionResponse create( @AuthenticationPrincipal Long memberId, @Valid @RequestBody FaceSessionCreateRequest request ) { - return faceRecognitionService.create(memberId, request.purpose()); + return faceRecognitionService.create(memberId, request.purpose(), request.floor()); } /** 카메라 프레임 한 장의 얼굴을 인식하고 출석을 처리한다. 받은 이미지는 모든 응답 경로에서 0으로 덮어쓴다. */ 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 34f69234..b7e92283 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)") + @Schema(description = "true면 QR 출석 안내를 띄운다(3회 초과 실패, DEC-004). OTHER_FLOOR면 항상 false") boolean qrRecommended ) { } @@ -35,7 +35,8 @@ public record Quality( } public record Recognition( - @Schema(description = "KNOWN, UNKNOWN, NOT_ATTEMPTED 중 하나") + @Schema(description = "KNOWN, UNKNOWN, NOT_ATTEMPTED, OTHER_FLOOR 중 하나. OTHER_FLOOR는 세션 층이 아닌 학생이고 이름·학번·출석이 없다", + allowableValues = {"KNOWN", "UNKNOWN", "NOT_ATTEMPTED", "OTHER_FLOOR"}) String status, @Schema(description = "학생 이름. status가 KNOWN일 때만 있다") String studentName, diff --git a/src/main/java/com/checkup/checkup/domain/face/dto/FaceSessionCreateRequest.java b/src/main/java/com/checkup/checkup/domain/face/dto/FaceSessionCreateRequest.java index 131962df..41418fb4 100644 --- a/src/main/java/com/checkup/checkup/domain/face/dto/FaceSessionCreateRequest.java +++ b/src/main/java/com/checkup/checkup/domain/face/dto/FaceSessionCreateRequest.java @@ -7,6 +7,8 @@ /** 관리자 얼굴 인식 세션 생성 요청. */ public record FaceSessionCreateRequest( @Schema(description = "출석 용도. DORMITORY(기숙사 입소) 또는 STUDY_ROOM(자습실)", example = "DORMITORY") - @NotNull AttendancePurpose purpose + @NotNull AttendancePurpose purpose, + @Schema(description = "카메라가 맡은 층. 3, 4, 5 중 하나. 없거나 다른 값이면 400 INVALID_FLOOR", example = "4") + Integer floor ) { } diff --git a/src/main/java/com/checkup/checkup/domain/face/dto/FaceSessionResponse.java b/src/main/java/com/checkup/checkup/domain/face/dto/FaceSessionResponse.java index 74e8908a..cdd82d2e 100644 --- a/src/main/java/com/checkup/checkup/domain/face/dto/FaceSessionResponse.java +++ b/src/main/java/com/checkup/checkup/domain/face/dto/FaceSessionResponse.java @@ -9,6 +9,7 @@ public record FaceSessionResponse( @Schema(description = "얼굴 인식 세션 id. 프레임 전송·종료 경로에 쓴다") UUID sessionId, @Schema(description = "출석 용도. DORMITORY 또는 STUDY_ROOM") AttendancePurpose purpose, + @Schema(description = "카메라가 맡은 층. 3, 4, 5 중 하나", example = "4") int floor, @Schema(description = "AI 준비 확인과 세션 생성이 성공하면 ACTIVE") String status ) { } diff --git a/src/main/java/com/checkup/checkup/domain/face/entity/FaceRecognitionSession.java b/src/main/java/com/checkup/checkup/domain/face/entity/FaceRecognitionSession.java index 67b00e34..17e51a65 100644 --- a/src/main/java/com/checkup/checkup/domain/face/entity/FaceRecognitionSession.java +++ b/src/main/java/com/checkup/checkup/domain/face/entity/FaceRecognitionSession.java @@ -31,6 +31,10 @@ public class FaceRecognitionSession { @Column(nullable = false, length = 20) private AttendancePurpose purpose; + /** 이 카메라가 맡은 층(3·4·5). 층 도입 전에 만든 세션은 null이다. */ + @Column + private Integer floor; + @Column(name = "created_at", nullable = false) private Instant createdAt; @@ -50,6 +54,7 @@ public static FaceRecognitionSession create( UUID id, Long adminMemberId, AttendancePurpose purpose, + Integer floor, Instant createdAt, Instant lastActivityAt ) { @@ -57,6 +62,7 @@ public static FaceRecognitionSession create( session.id = id; session.adminMemberId = adminMemberId; session.purpose = purpose; + session.floor = floor; session.createdAt = createdAt; session.lastActivityAt = lastActivityAt; session.active = true; 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 1bc27f97..1d49fad9 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 @@ -47,6 +47,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 final FaceTemplateRepository faceTemplateRepository; private final FaceSessionStore faceSessionStore; @@ -87,11 +88,15 @@ public FaceRecognitionService( /** * 관리자 카메라 페이지의 얼굴 인식 세션을 만든다. 출석 대상(필수 동의·호실)이고 얼굴을 등록한 학생이 후보다. + * 후보는 층과 상관없이 전체 출석 대상이다. 다른 층 학생을 이 층 학생으로 잘못 맞히지 않고 {@code OTHER_FLOOR}로 알리기 위해서다(#253). + * 같은 관리자의 다른 세션과 겹쳐도 거부하지 않는다. 층을 바꾸면 웹이 이전 세션을 닫고 새로 만든다. * * @param adminMemberId 세션의 관리자 회원 id * @param purpose 출석 용도 - * @return 새 세션 id와 용도 - * @throws CustomException 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403), + * @param floor 카메라가 맡은 층(3·4·5). 이 층 학생만 출석 처리한다 + * @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_AI_BAD_GATEWAY}(502). @@ -99,8 +104,11 @@ public FaceRecognitionService( * {@code GlobalExceptionHandler}가 {@link ErrorCode#FACE_AI_UNAVAILABLE}(503), * {@link ErrorCode#FACE_AI_TIMEOUT}(504), {@link ErrorCode#FACE_AI_BAD_GATEWAY}(502) 등으로 바꾼다 */ - public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose) { + public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose, Integer floor) { adminVerifier.verify(adminMemberId); + if (floor == null || !CAMERA_FLOORS.contains(floor)) { + throw new CustomException(ErrorCode.INVALID_FLOOR); + } List templates = eligibleTemplates(); if (templates.isEmpty()) { throw new CustomException(ErrorCode.FACE_NO_ENROLLED_STUDENTS); @@ -112,7 +120,7 @@ public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose) UUID sessionId = UUID.randomUUID(); Instant now = clock.instant(); // AI를 부르기 전에 세션 소유 정보를 먼저 저장한다. 그래야 AI 쪽 정리가 실패해도 다시 시도할 수 있다. - faceSessionStore.create(sessionId, adminMemberId, purpose, + faceSessionStore.create(sessionId, adminMemberId, purpose, floor, templates.stream().map(template -> template.getStudent().getId()).toList(), now, now.minus(properties.minFrameInterval())); try { @@ -126,12 +134,13 @@ public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose) } throw e; } - return new FaceSessionResponse(sessionId, purpose, "ACTIVE"); + return new FaceSessionResponse(sessionId, purpose, floor, "ACTIVE"); } /** * 카메라 프레임 한 장에서 얼굴을 인식하고, 현재 세션 후보로 확인된 학생을 출석 처리한다. * AI가 후보 밖의 학생을 지목하면 출석으로 인정하지 않고 {@code UNKNOWN}으로 낮춘다. 결과는 최근 인식 기록에도 남긴다(#143). + * 세션 층이 아닌 학생은 이름·학번·출석 없이 {@code OTHER_FLOOR}로 알린다(#253). * 프레임 이미지는 처리 뒤 메모리에서 지우고 저장하지 않는다. * * @param frameIdHeader 프레임 id(선택, 최대 128자). 없으면 새로 만든다 @@ -330,13 +339,20 @@ private FaceFrameResponse.Face toPublicFace( String studentName = null; Integer studentNumber = null; String attendance = null; + boolean qrRecommended = face.qrRecommended(); if ("KNOWN".equals(status)) { Long dataGsmStudentId = canonicalStudentId(face.recognition().studentId()); var student = dataGsmStudentId == null ? java.util.Optional.empty() : studentRepository.findByDatagsmStudentId(dataGsmStudentId); - if (student.isPresent() + boolean candidate = student.isPresent() && session.candidateStudentIds().contains(student.get().getId()) - && student.get().isAttendanceEligible()) { + && student.get().isAttendanceEligible(); + if (candidate && isOtherFloor(session, student.get())) { + // 다른 층 학생은 이 카메라 화면에 신원을 보이지 않고 출석·최근 인식에도 남기지 않는다(#253). + // 층을 잘못 찾은 학생에게 QR 안내는 도움이 안 되므로 띄우지 않는다. + status = "OTHER_FLOOR"; + qrRecommended = false; + } else if (candidate) { studentName = student.get().getName(); studentNumber = student.get().getStudentNumber(); AttendanceRecordResult recorded = attendanceService.markAttendedFor( @@ -356,7 +372,7 @@ private FaceFrameResponse.Face toPublicFace( status = "UNKNOWN"; } } - if (attendance == null && face.qrRecommended()) { + if (attendance == null && qrRecommended) { // 못 알아본 얼굴은 AI가 QR을 안내할 때만 한 줄 남기고, 다른 학생의 이름을 만들지 않는다. recordQuietly(session, face.trackId(), FaceRecognitionResult.FAILED, null, verifiedAt); } @@ -365,7 +381,12 @@ private FaceFrameResponse.Face toPublicFace( return new FaceFrameResponse.Face(face.trackId(), face.bbox(), face.landmarks(), new FaceFrameResponse.Quality(quality.brightness(), quality.sharpness(), quality.issues()), new FaceFrameResponse.Recognition(status, studentName, studentNumber, attendance), - face.attempts(), face.qrRecommended()); + face.attempts(), qrRecommended); + } + + /** 층 도입 전에 만든 세션(층 null)은 층을 따지지 않는다. */ + private static boolean isOtherFloor(FaceSessionView session, Student student) { + return session.floor() != null && !session.floor().equals(student.getDormitoryFloor()); } /** diff --git a/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionStore.java b/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionStore.java index 76a32bce..2970cdc9 100644 --- a/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionStore.java +++ b/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionStore.java @@ -33,10 +33,10 @@ public class FaceSessionStore { * @throws CustomException 후보 학생 중 저장되지 않은 학생이 있으면 {@link ErrorCode#FACE_NO_ENROLLED_STUDENTS} */ @Transactional - public void create(UUID sessionId, Long adminMemberId, AttendancePurpose purpose, List studentIds, - Instant createdAt, Instant initialLastActivityAt) { - sessionRepository.save( - FaceRecognitionSession.create(sessionId, adminMemberId, purpose, createdAt, initialLastActivityAt)); + public void create(UUID sessionId, Long adminMemberId, AttendancePurpose purpose, Integer floor, + List studentIds, Instant createdAt, Instant initialLastActivityAt) { + sessionRepository.save(FaceRecognitionSession.create( + sessionId, adminMemberId, purpose, floor, createdAt, initialLastActivityAt)); // 후보 수만큼 insert하지 않고 한 문장으로 저장한다. 저장되지 않은 학생이 있으면 개수가 모자라 트랜잭션째 취소된다. if (candidateRepository.insertAll(sessionId, studentIds) != studentIds.size()) { throw new CustomException(ErrorCode.FACE_NO_ENROLLED_STUDENTS); @@ -56,7 +56,7 @@ public FaceSessionView findOwned(UUID sessionId, Long adminMemberId) { .map(candidate -> candidate.getStudent().getId()) .collect(Collectors.toUnmodifiableSet()); return new FaceSessionView(session.getId(), session.getAdminMemberId(), session.getPurpose(), - session.getLastActivityAt(), session.isActive(), candidateIds); + session.getFloor(), session.getLastActivityAt(), session.isActive(), candidateIds); } /** @@ -169,6 +169,6 @@ private static FaceSessionView view(FaceRecognitionSession session, Set ids = candidates.stream().map(candidate -> candidate.getStudent().getId()) .collect(Collectors.toUnmodifiableSet()); return new FaceSessionView(session.getId(), session.getAdminMemberId(), session.getPurpose(), - session.getLastActivityAt(), session.isActive(), ids); + session.getFloor(), session.getLastActivityAt(), session.isActive(), ids); } } diff --git a/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionView.java b/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionView.java index 1cf6a530..57033b1f 100644 --- a/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionView.java +++ b/src/main/java/com/checkup/checkup/domain/face/service/FaceSessionView.java @@ -6,11 +6,12 @@ import java.util.Set; import java.util.UUID; -/** 얼굴 인식 세션의 읽기 전용 상태와 후보 학생 DB id 목록. */ +/** 얼굴 인식 세션의 읽기 전용 상태와 후보 학생 DB id 목록. {@code floor}는 카메라가 맡은 층이고 층 도입 전 세션은 null이다. */ public record FaceSessionView( UUID id, Long adminMemberId, AttendancePurpose purpose, + Integer floor, Instant lastActivityAt, boolean active, Set candidateStudentIds diff --git a/src/main/java/com/checkup/checkup/global/exception/ErrorCode.java b/src/main/java/com/checkup/checkup/global/exception/ErrorCode.java index bd91bee2..72b1d6f2 100644 --- a/src/main/java/com/checkup/checkup/global/exception/ErrorCode.java +++ b/src/main/java/com/checkup/checkup/global/exception/ErrorCode.java @@ -61,6 +61,7 @@ public enum ErrorCode { FACE_ENROLLMENT_NOT_ELIGIBLE(HttpStatus.FORBIDDEN, "현재 얼굴 등록 대상이 아닙니다."), FACE_ENROLLMENT_MULTIPLE_IDENTITIES(HttpStatus.UNPROCESSABLE_ENTITY, "영상에 얼굴이 여러 명 포함되어 있습니다. 본인만 촬영해 주세요."), FACE_ENROLLMENT_LOW_LIGHT(HttpStatus.UNPROCESSABLE_ENTITY, "영상이 어둡습니다. 밝은 곳에서 다시 촬영해 주세요."), + INVALID_FLOOR(HttpStatus.BAD_REQUEST, "얼굴 인식 층은 3, 4, 5층 중 하나여야 합니다."), FACE_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "얼굴 인식 세션이 없거나 종료되었습니다."), FACE_NO_ENROLLED_STUDENTS(HttpStatus.CONFLICT, "현재 인식할 수 있는 등록 학생이 없습니다."), FACE_TOO_MANY_CANDIDATES(HttpStatus.UNPROCESSABLE_ENTITY, "인식 대상 학생이 허용 인원을 초과했습니다."), diff --git a/src/main/resources/db/migration/V21__add_face_session_floor.sql b/src/main/resources/db/migration/V21__add_face_session_floor.sql new file mode 100644 index 00000000..9a2997de --- /dev/null +++ b/src/main/resources/db/migration/V21__add_face_session_floor.sql @@ -0,0 +1,3 @@ +-- 관리자 카메라가 맡은 층(#253). 이전에 만든 세션은 층이 없으므로 NULL을 허용한다. +ALTER TABLE face_recognition_session + ADD COLUMN floor INTEGER CHECK (floor BETWEEN 3 AND 5); diff --git a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionLogServiceTest.java b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionLogServiceTest.java index f73a3602..4bb809d1 100644 --- a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionLogServiceTest.java +++ b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionLogServiceTest.java @@ -119,7 +119,7 @@ void deleteExpiredRemovesPreviousDays() { } private static FaceSessionView session() { - return new FaceSessionView(SESSION_ID, ADMIN_ID, AttendancePurpose.DORMITORY, BEFORE_BOUNDARY, true, Set.of(40L)); + return new FaceSessionView(SESSION_ID, ADMIN_ID, AttendancePurpose.DORMITORY, 4, BEFORE_BOUNDARY, true, Set.of(40L)); } private static FaceRecognitionLog log(FaceRecognitionResult result, Student student) { 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 6f870941..0e437bd0 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 @@ -158,6 +158,7 @@ void recordsOnlyKnownCandidates() { given(student.isAttendanceEligible()).willReturn(true); given(student.getName()).willReturn("Student Name"); given(student.getStudentNumber()).willReturn(15); + given(student.getDormitoryFloor()).willReturn(3); given(studentRepository.findByDatagsmStudentId(DATAGSM_STUDENT_ID)).willReturn(Optional.of(student)); given(studentRepository.countByIdInAndDormitoryRoomIsNotNull(Set.of(STUDENT_DB_ID))).willReturn(1L); given(attendanceService.markAttendedFor(student, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE)) @@ -300,12 +301,12 @@ void ownershipIsSavedBeforeAiSessionForCleanupRetry() throws Exception { .given(aiFaceClient).deleteSession(any()); org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.create(ADMIN_ID, - com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY)) + com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY, 3)) .isInstanceOf(AiFaceException.class); var order = inOrder(sessionStore, aiFaceClient); order.verify(sessionStore).create(any(), eq(ADMIN_ID), - eq(com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY), + eq(com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY), eq(3), eq(List.of(STUDENT_DB_ID)), any(), any()); order.verify(aiFaceClient).createSession(any(), any()); verify(sessionStore).markInactive(any(), eq(ADMIN_ID)); @@ -328,12 +329,12 @@ void tooManyCandidatesAreRejectedByConfiguredMaximum() { 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)) + com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY, 3)) .isInstanceOfSatisfying(com.checkup.checkup.global.exception.CustomException.class, error -> assertThat(error.getErrorCode()).isEqualTo( com.checkup.checkup.global.exception.ErrorCode.FACE_TOO_MANY_CANDIDATES)); - verify(sessionStore, never()).create(any(), any(), any(), any(), any(), any()); + verify(sessionStore, never()).create(any(), any(), any(), any(), any(), any(), any()); verify(aiFaceClient, never()).createSession(any(), any()); } @@ -343,15 +344,43 @@ 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)) + com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY, 3)) .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(sessionStore, never()).create(any(), any(), any(), any(), any(), any(), any()); verify(aiFaceClient, never()).createSession(any(), any()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.NullSource + @org.junit.jupiter.params.provider.ValueSource(ints = {0, 2, 6}) + @DisplayName("층이 없거나 3·4·5가 아니면 세션을 만들지 않고 400 INVALID_FLOOR다") + void invalidFloorIsRejected(Integer floor) { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.create(ADMIN_ID, + AttendancePurpose.DORMITORY, floor)) + .isInstanceOfSatisfying(com.checkup.checkup.global.exception.CustomException.class, + error -> assertThat(error.getErrorCode()).isEqualTo( + com.checkup.checkup.global.exception.ErrorCode.INVALID_FLOOR)); + + verifyNoInteractions(templateRepository, sessionStore, aiFaceClient); + } + + @Test + @DisplayName("세션을 만들면 요청한 층을 저장하고 응답에 돌려준다") + void createdSessionKeepsFloor() throws Exception { + FaceTemplate template = versionedTemplate(STUDENT_DB_ID, DATAGSM_STUDENT_ID, 0L, vectorJson(0)); + given(templateRepository.findAllEligible()).willReturn(List.of(template)); + + var response = service.create(ADMIN_ID, AttendancePurpose.DORMITORY, 4); + + assertThat(response.floor()).isEqualTo(4); + assertThat(response.status()).isEqualTo("ACTIVE"); + verify(sessionStore).create(eq(response.sessionId()), eq(ADMIN_ID), eq(AttendancePurpose.DORMITORY), eq(4), + eq(List.of(STUDENT_DB_ID)), any(), any()); + } + @Test @DisplayName("후보 상한을 따로 정하지 않으면 기본 200명이다") void defaultMaximumIs200() { @@ -494,6 +523,46 @@ void logFailureDoesNotBreakRecognition() { assertThat(response.faces().getFirst().recognition().attendance()).isEqualTo("RECORDED"); } + @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)))); + 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().studentName()).isNull(); + assertThat(face.recognition().studentNumber()).isNull(); + assertThat(face.recognition().attendance()).isNull(); + assertThat(face.qrRecommended()).isFalse(); + verifyNoInteractions(attendanceService, logService); + } + + @Test + @DisplayName("층 도입 전에 만든 세션은 층을 따지지 않고 KNOWN으로 출석 처리한다") + void sessionWithoutFloorIgnoresStudentFloor() { + FaceSessionView legacy = new FaceSessionView(SESSION_ID, ADMIN_ID, AttendancePurpose.DORMITORY, null, + NOW.minusSeconds(2), true, Set.of(STUDENT_DB_ID)); + givenFrame(frame("KNOWN", DATAGSM_STUDENT_ID.toString())); + given(sessionStore.findOwned(SESSION_ID, ADMIN_ID)).willReturn(legacy); + givenEligibleStudent(); + given(eligibleStudent.getDormitoryFloor()).willReturn(5); + given(attendanceService.markAttendedFor(eligibleStudent, AttendancePurpose.DORMITORY, NOW, AttendanceMethod.FACE)) + .willReturn(AttendanceRecordResult.RECORDED); + + var recognition = service.recognize(ADMIN_ID, SESSION_ID, "frame-1", "image/jpeg", new byte[]{1, 2}) + .faces().getFirst().recognition(); + + assertThat(recognition.status()).isEqualTo("KNOWN"); + assertThat(recognition.attendance()).isEqualTo("RECORDED"); + } + /** 현재 세션 후보가 학생 한 명이고, 프레임을 보내면 AI가 주어진 결과를 돌려주게 한다. */ private FaceSessionView givenFrame(AiFaceFrameResponse frame) { FaceSessionView session = session(Set.of(STUDENT_DB_ID)); @@ -511,6 +580,7 @@ private void givenEligibleStudent() { given(student.isAttendanceEligible()).willReturn(true); given(student.getName()).willReturn("Student Name"); given(student.getStudentNumber()).willReturn(15); + given(student.getDormitoryFloor()).willReturn(3); given(studentRepository.findByDatagsmStudentId(DATAGSM_STUDENT_ID)).willReturn(Optional.of(student)); } @@ -521,7 +591,7 @@ private static AiFaceFrameResponse.FaceResult qrRecommendedFace() { } private static FaceSessionView session(Set candidates) { - return new FaceSessionView(SESSION_ID, ADMIN_ID, AttendancePurpose.DORMITORY, + return new FaceSessionView(SESSION_ID, ADMIN_ID, AttendancePurpose.DORMITORY, 3, NOW.minusSeconds(2), true, candidates); } diff --git a/src/test/java/com/checkup/checkup/domain/face/service/FaceSessionStoreTest.java b/src/test/java/com/checkup/checkup/domain/face/service/FaceSessionStoreTest.java index 7843080f..bf0b6b0b 100644 --- a/src/test/java/com/checkup/checkup/domain/face/service/FaceSessionStoreTest.java +++ b/src/test/java/com/checkup/checkup/domain/face/service/FaceSessionStoreTest.java @@ -78,11 +78,12 @@ void tearDown() { void savesAllCandidates() { UUID sessionId = UUID.randomUUID(); - sessionStore.create(sessionId, adminMemberId, AttendancePurpose.DORMITORY, studentIds, + sessionStore.create(sessionId, adminMemberId, AttendancePurpose.DORMITORY, 4, studentIds, Instant.now(), Instant.now()); - assertThat(sessionStore.findOwned(sessionId, adminMemberId).candidateStudentIds()) - .containsExactlyInAnyOrderElementsOf(studentIds); + FaceSessionView saved = sessionStore.findOwned(sessionId, adminMemberId); + assertThat(saved.candidateStudentIds()).containsExactlyInAnyOrderElementsOf(studentIds); + assertThat(saved.floor()).isEqualTo(4); assertThat(sessionStore.isOwnedActive(sessionId, adminMemberId)).isTrue(); } @@ -93,7 +94,7 @@ void unknownStudentCancelsWholeSession() { List withMissing = new ArrayList<>(studentIds); withMissing.add(-1L); - assertThatThrownBy(() -> sessionStore.create(sessionId, adminMemberId, AttendancePurpose.DORMITORY, + assertThatThrownBy(() -> sessionStore.create(sessionId, adminMemberId, AttendancePurpose.DORMITORY, 4, withMissing, Instant.now(), Instant.now())) .isInstanceOfSatisfying(CustomException.class, e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FACE_NO_ENROLLED_STUDENTS));