Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,23 @@ public interface FaceTemplateRepository extends JpaRepository<FaceTemplate, Long
void deleteByStudent_Id(Long studentId);

/**
* 출석 대상 학생의 템플릿만 학생 id 순으로 읽는다. 얼굴 인식 세션을 만들 때 쓴다.
* 호실 번호가 {@code firstRoom}~{@code lastRoom}인 출석 대상 학생의 템플릿만 학생 id 순으로 읽는다.
* 얼굴 인식 세션을 만들 때 고른 층 학생만 후보로 쓰려고 부른다(#256).
* 조건은 {@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
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<FaceTemplate> findAllEligible();
List<FaceTemplate> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Integer> CAMERA_FLOORS = Set.of(3, 4, 5);
private static final int ROOMS_PER_FLOOR = 100;

private final FaceTemplateRepository faceTemplateRepository;
private final FaceSessionStore faceSessionStore;
Expand Down Expand Up @@ -87,8 +88,8 @@ public FaceRecognitionService(
}

/**
* 관리자 카메라 페이지의 얼굴 인식 세션을 만든다. 출석 대상(필수 동의·호실)이고 얼굴을 등록한 학생이 후보다.
* 후보는 층과 상관없이 전체 출석 대상이다. 다른 층 학생을 이 층 학생으로 잘못 맞히지 않고 {@code OTHER_FLOOR}로 알리기 위해서다(#253).
* 관리자 카메라 페이지의 얼굴 인식 세션을 만든다. 고른 층의 출석 대상(필수 동의·호실)이고 얼굴을 등록한 학생이 후보다.
* 층 학생만 비교해 인식을 빠르게 한다. 다른 층 학생은 후보에 없으므로 인식 실패로 처리된다(#256).
* 같은 관리자의 다른 세션과 겹쳐도 거부하지 않는다. 층을 바꾸면 웹이 이전 세션을 닫고 새로 만든다.
*
* @param adminMemberId 세션의 관리자 회원 id
Expand All @@ -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),
Expand All @@ -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<FaceTemplate> templates = eligibleTemplates();
List<FaceTemplate> templates = eligibleTemplates(floor);
if (templates.isEmpty()) {
throw new CustomException(ErrorCode.FACE_NO_ENROLLED_STUDENTS);
}
Expand Down Expand Up @@ -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자). 없으면 새로 만든다
Expand Down Expand Up @@ -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(
Expand All @@ -368,7 +365,7 @@ private FaceFrameResponse.Face toPublicFace(
}
}
} else {
// Spring이 관리하는 현재 세션 후보 목록 밖의 학생을 AI가 지목해도 절대 출석으로 인정하지 않는다.
// Spring이 관리하는 현재 세션 후보 목록 밖이거나 다른 층 학생을 AI가 지목해도 절대 출석으로 인정하지 않는다.
status = "UNKNOWN";
}
}
Expand All @@ -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());
}

/**
Expand Down Expand Up @@ -453,8 +450,9 @@ private static Long canonicalStudentId(String value) {
}
}

private List<FaceTemplate> eligibleTemplates() {
return faceTemplateRepository.findAllEligible();
private List<FaceTemplate> eligibleTemplates(int floor) {
return faceTemplateRepository.findAllEligibleInRooms(floor * ROOMS_PER_FLOOR,
floor * ROOMS_PER_FLOOR + ROOMS_PER_FLOOR - 1);
}

private AiFaceSessionRequest sessionRequest(List<FaceTemplate> templates) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,30 @@ void setUp() {

@Test
@DisplayName("동의 2종·호실·DataGSM 학생 id가 모두 있는 학생의 템플릿만 학생 id 순으로 읽는다")
void findAllEligibleReturnsOnlyAttendanceEligibleStudents() {
List<FaceTemplate> templates = repository.findAllEligible();
void findAllEligibleInRoomsReturnsOnlyAttendanceEligibleStudents() {
List<FaceTemplate> 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() {
Expand All @@ -87,7 +104,7 @@ void findAllEligibleByStudentIdInSkipsIneligibleStudents() {
@Test
@DisplayName("쿼리 결과는 Student.isAttendanceEligible()과 같은 판단이다")
void queryMatchesEntityRule() {
List<FaceTemplate> templates = repository.findAllEligible();
List<FaceTemplate> templates = repository.findAllEligibleInRooms(300, 399);

assertThat(templates).allSatisfy(template -> {
assertThat(template.getStudent().isAttendanceEligible()).isTrue();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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);

Expand All @@ -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<AiFaceSessionRequest> 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() {
Expand Down Expand Up @@ -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
Expand Down
Loading