From 115f6297e22587500256f57d4a43d35120fefd1d Mon Sep 17 00:00:00 2001 From: zizon-seoha Date: Sun, 11 Oct 2026 23:41:53 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(face):=20=EC=96=BC=EA=B5=B4=20?= =?UTF-8?q?=EC=9E=AC=EB=93=B1=EB=A1=9D=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FacePayloadLimitFilter.java | 2 +- .../domain/face/entity/FaceTemplate.java | 20 +++- .../repository/FaceTemplateRepository.java | 8 ++ .../face/service/FaceEnrollmentService.java | 19 ++- .../face/service/FaceEnrollmentStore.java | 15 +-- .../face/service/FaceRecognitionService.java | 70 +++++++++++- .../V10__add_face_template_revision.sql | 2 + .../FacePayloadLimitFilterTest.java | 12 +- .../service/FaceEnrollmentServiceTest.java | 42 +++++-- .../service/FaceRecognitionServiceTest.java | 108 +++++++++++++++++- 10 files changed, 258 insertions(+), 40 deletions(-) create mode 100644 src/main/resources/db/migration/V10__add_face_template_revision.sql diff --git a/src/main/java/com/checkup/checkup/domain/face/controller/FacePayloadLimitFilter.java b/src/main/java/com/checkup/checkup/domain/face/controller/FacePayloadLimitFilter.java index c2cbaadc..11ce5da2 100644 --- a/src/main/java/com/checkup/checkup/domain/face/controller/FacePayloadLimitFilter.java +++ b/src/main/java/com/checkup/checkup/domain/face/controller/FacePayloadLimitFilter.java @@ -102,7 +102,7 @@ private boolean canEnroll(HttpServletResponse response) throws IOException { return true; } try { - faceEnrollmentService.verifyEnrollable(memberId); + faceEnrollmentService.verifyEnrollmentAllowed(memberId); return true; } catch (CustomException e) { writeError(response, e.getErrorCode()); diff --git a/src/main/java/com/checkup/checkup/domain/face/entity/FaceTemplate.java b/src/main/java/com/checkup/checkup/domain/face/entity/FaceTemplate.java index 0b1fa5cc..e5d3cb2d 100644 --- a/src/main/java/com/checkup/checkup/domain/face/entity/FaceTemplate.java +++ b/src/main/java/com/checkup/checkup/domain/face/entity/FaceTemplate.java @@ -11,6 +11,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import jakarta.persistence.Version; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -47,6 +48,11 @@ public class FaceTemplate { @Column(name = "vectors_json", nullable = false, columnDefinition = "TEXT") private String vectorsJson; + /** Monotonic revision lets recognition sessions detect a concurrent re-enrollment. */ + @Version + @Column(name = "revision", nullable = false) + private long revision; + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -54,11 +60,15 @@ public class FaceTemplate { public static FaceTemplate create(Student student, AiFaceModel model, String vectorsJson) { FaceTemplate template = new FaceTemplate(); template.student = student; - template.modelId = model.modelId(); - template.modelVersion = model.version(); - template.dimension = model.dimension(); - template.normalization = model.normalization(); - template.vectorsJson = vectorsJson; + template.replace(model, vectorsJson); return template; } + + public void replace(AiFaceModel model, String vectorsJson) { + this.modelId = model.modelId(); + this.modelVersion = model.version(); + this.dimension = model.dimension(); + this.normalization = model.normalization(); + this.vectorsJson = vectorsJson; + } } 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 75a3943f..9e239834 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 @@ -1,8 +1,12 @@ package com.checkup.checkup.domain.face.repository; 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; +import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; @@ -14,6 +18,10 @@ public interface FaceTemplateRepository extends JpaRepository findByStudent_Id(Long studentId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT template FROM FaceTemplate template WHERE template.student.id = :studentId") + Optional findForUpdateByStudentId(@Param("studentId") Long studentId); + @EntityGraph(attributePaths = "student") List findAllByOrderByStudent_IdAsc(); diff --git a/src/main/java/com/checkup/checkup/domain/face/service/FaceEnrollmentService.java b/src/main/java/com/checkup/checkup/domain/face/service/FaceEnrollmentService.java index 3af6fe02..5ce66d0d 100644 --- a/src/main/java/com/checkup/checkup/domain/face/service/FaceEnrollmentService.java +++ b/src/main/java/com/checkup/checkup/domain/face/service/FaceEnrollmentService.java @@ -27,6 +27,7 @@ public class FaceEnrollmentService { private final FaceEnrollmentStore enrollmentStore; private final AiFaceClient aiFaceClient; + private final FaceRecognitionService faceRecognitionService; private final FaceProperties properties; private final ObjectMapper objectMapper; @@ -40,7 +41,6 @@ public class FaceEnrollmentService { * @throws CustomException 학생이 아니면 {@link ErrorCode#MISSING_STUDENT_INFO}(403), * 필수 동의가 없으면 {@link ErrorCode#FACE_CONSENT_REQUIRED}(403), * 등록 대상이 아니면 {@link ErrorCode#FACE_ENROLLMENT_NOT_ELIGIBLE}(403), - * 이미 등록했으면 {@link ErrorCode#FACE_ALREADY_REGISTERED}(409), * 영상이 없거나 형식이 다르면 {@link ErrorCode#FACE_INVALID_MEDIA}(400), * 너무 크면 {@link ErrorCode#FACE_UPLOAD_TOO_LARGE}(413), * AI 응답이 계약과 다르면 {@link ErrorCode#FACE_AI_BAD_GATEWAY}(502). @@ -51,7 +51,7 @@ public class FaceEnrollmentService { */ public FaceEnrollmentResponse enroll(Long memberId, String contentType, byte[] rawVideo) { try { - verifyEnrollable(memberId); + verifyEnrollmentAllowed(memberId); if (rawVideo == null || rawVideo.length == 0) { throw new CustomException(ErrorCode.FACE_INVALID_MEDIA); } @@ -60,7 +60,7 @@ public FaceEnrollmentResponse enroll(Long memberId, String contentType, byte[] r } MediaType mediaType = mediaType(contentType); - // AI 네트워크 호출은 짧은 저장 트랜잭션보다 먼저 한다. 동시 요청 경합은 DB unique 제약이 막는다. + // AI 영상 추출은 저장 트랜잭션보다 먼저 한다. 실패하면 기존 템플릿은 그대로 남는다. AiFaceEnrollmentResponse extracted = aiFaceClient.extract(rawVideo, mediaType); validate(extracted); String vectorsJson; @@ -69,11 +69,14 @@ public FaceEnrollmentResponse enroll(Long memberId, String contentType, byte[] r } catch (JacksonException e) { throw new CustomException(ErrorCode.FACE_AI_BAD_GATEWAY); } + Long studentId; try { - enrollmentStore.saveTemplate(memberId, extracted.model(), vectorsJson); + studentId = enrollmentStore.saveOrReplaceTemplate(memberId, extracted.model(), vectorsJson); } catch (DataIntegrityViolationException e) { throw new CustomException(ErrorCode.FACE_ALREADY_REGISTERED); } + // 저장 트랜잭션이 끝난 뒤 활성 AI 세션을 새 벡터로 갱신한다. + faceRecognitionService.refreshStudentTemplate(studentId); return FaceEnrollmentResponse.registered(); } finally { if (rawVideo != null) { @@ -89,10 +92,9 @@ public FaceEnrollmentResponse enroll(Long memberId, String contentType, byte[] r * @param memberId 세션의 회원 id * @throws CustomException 학생이 아니면 {@link ErrorCode#MISSING_STUDENT_INFO}(403), * 필수 동의가 없으면 {@link ErrorCode#FACE_CONSENT_REQUIRED}(403), - * 등록 대상이 아니면 {@link ErrorCode#FACE_ENROLLMENT_NOT_ELIGIBLE}(403), - * 이미 등록했으면 {@link ErrorCode#FACE_ALREADY_REGISTERED}(409) + * 등록 대상이 아니면 {@link ErrorCode#FACE_ENROLLMENT_NOT_ELIGIBLE}(403) */ - public void verifyEnrollable(Long memberId) { + public void verifyEnrollmentAllowed(Long memberId) { FaceEnrollmentStore.FaceStatusResponseData status = enrollmentStore.status(memberId); if (!status.consented()) { throw new CustomException(ErrorCode.FACE_CONSENT_REQUIRED); @@ -100,9 +102,6 @@ public void verifyEnrollable(Long memberId) { if (!status.eligible()) { throw new CustomException(ErrorCode.FACE_ENROLLMENT_NOT_ELIGIBLE); } - if (status.enrolled()) { - throw new CustomException(ErrorCode.FACE_ALREADY_REGISTERED); - } } private static MediaType mediaType(String raw) { diff --git a/src/main/java/com/checkup/checkup/domain/face/service/FaceEnrollmentStore.java b/src/main/java/com/checkup/checkup/domain/face/service/FaceEnrollmentStore.java index ecf743ef..aae4a99f 100644 --- a/src/main/java/com/checkup/checkup/domain/face/service/FaceEnrollmentStore.java +++ b/src/main/java/com/checkup/checkup/domain/face/service/FaceEnrollmentStore.java @@ -48,16 +48,16 @@ public FaceStatusResponseData status(Long memberId) { } /** - * AI가 뽑은 대표 벡터를 학생의 얼굴 템플릿으로 저장한다. 등록은 한 번만 할 수 있다. + * AI가 뽑은 대표 벡터를 새 템플릿으로 저장하거나 기존 템플릿에 교체한다. * * @param vectorsJson 대표 벡터 목록 JSON. 로그에 남기지 않는다 * @throws CustomException 학생이 아니면 {@link ErrorCode#MISSING_STUDENT_INFO}(403), * 필수 동의가 없으면 {@link ErrorCode#FACE_CONSENT_REQUIRED}(403), * DataGSM 학생 id나 호실이 없으면 {@link ErrorCode#FACE_ENROLLMENT_NOT_ELIGIBLE}(403), - * 이미 등록했으면 {@link ErrorCode#FACE_ALREADY_REGISTERED} + * @return AI 세션 갱신에 사용할 내부 학생 id */ @Transactional - public void saveTemplate(Long memberId, AiFaceModel model, String vectorsJson) { + public Long saveOrReplaceTemplate(Long memberId, AiFaceModel model, String vectorsJson) { Student student = findStudent(memberId); if (!student.hasRequiredConsent()) { throw new CustomException(ErrorCode.FACE_CONSENT_REQUIRED); @@ -65,10 +65,11 @@ public void saveTemplate(Long memberId, AiFaceModel model, String vectorsJson) { if (!isEligible(student)) { throw new CustomException(ErrorCode.FACE_ENROLLMENT_NOT_ELIGIBLE); } - if (faceTemplateRepository.existsByStudent_Id(student.getId())) { - throw new CustomException(ErrorCode.FACE_ALREADY_REGISTERED); - } - faceTemplateRepository.save(FaceTemplate.create(student, model, vectorsJson)); + FaceTemplate template = faceTemplateRepository.findForUpdateByStudentId(student.getId()) + .orElseGet(() -> FaceTemplate.create(student, model, vectorsJson)); + template.replace(model, vectorsJson); + faceTemplateRepository.saveAndFlush(template); + return student.getId(); } /** 학생의 얼굴 템플릿을 지운다. 졸업·자퇴 처리에서 쓰며, 호출한 쪽 트랜잭션과 상관없이 지우도록 새 트랜잭션에서 한다. */ 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 a90c8f95..628b0e26 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 @@ -34,16 +34,19 @@ import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.Semaphore; +import java.util.stream.Collectors; /** AI 세션을 시작하고, 카메라 프레임 속도를 제한하며, 출석 전에 얼굴 일치 결과를 검증한다. */ @Slf4j @Service public class FaceRecognitionService { private static final int VECTOR_DIMENSION = 256; + private static final int MAX_TEMPLATE_REFRESH_ATTEMPTS = 3; private final FaceTemplateRepository faceTemplateRepository; private final FaceSessionStore faceSessionStore; @@ -263,6 +266,45 @@ public void removeStudent(Long studentId) { faceSessionStore.findWithStudent(studentId).forEach(this::closeOwnedQuietly); } + /** 학생 템플릿을 재등록한 뒤 그 학생이 포함된 활성 AI 세션의 후보 벡터를 교체한다. */ + public void refreshStudentTemplate(Long studentId) { + for (FaceSessionView session : faceSessionStore.findWithStudent(studentId)) { + if (!session.active()) { + continue; + } + try { + refreshSessionTemplate(session, studentId); + } catch (RuntimeException e) { + // 갱신이 불확실한 세션은 오래된 벡터로 출석을 처리하지 않도록 fail closed 한다. + log.warn("Face session template refresh failed: reason={}", e.getClass().getSimpleName()); + closeOwnedQuietly(session); + } + } + } + + private void refreshSessionTemplate(FaceSessionView session, Long studentId) { + for (int attempt = 0; attempt < MAX_TEMPLATE_REFRESH_ATTEMPTS; attempt++) { + if (!faceSessionStore.isOwnedActive(session.id(), session.adminMemberId())) { + deleteAiSessionQuietly(session.id()); + return; + } + + CandidateRequestSnapshot snapshot = candidateRequestSnapshot(session.candidateStudentIds(), studentId); + aiFaceClient.createSession(session.id(), snapshot.request()); + + // 세션 종료와 AI PUT의 경합으로 종료 직후 AI 세션이 다시 만들어졌다면 제거한다. + if (!faceSessionStore.isOwnedActive(session.id(), session.adminMemberId())) { + deleteAiSessionQuietly(session.id()); + return; + } + Map currentRevisions = candidateTemplateRevisions(session.candidateStudentIds()); + if (currentRevisions.equals(snapshot.templateRevisions())) { + return; + } + } + throw new IllegalStateException("Face template changed repeatedly during session refresh"); + } + /** * 유휴 시간이 지났고 처리 중인 프레임이 없는 세션을 닫는다. 정리 작업이 주기적으로 부른다. * AI 쪽 삭제가 실패해 비활성 상태로 남은 세션도 여기서 다시 정리한다. @@ -415,13 +457,36 @@ private static boolean eligibleStudent(Student student) { } private AiFaceSessionRequest candidateRequest(Set studentIds) { + return candidateRequestSnapshot(studentIds, null).request(); + } + + private CandidateRequestSnapshot candidateRequestSnapshot(Set studentIds, Long refreshedStudentId) { + List templates = candidateTemplates(studentIds); + if (refreshedStudentId != null && templates.stream() + .noneMatch(template -> refreshedStudentId.equals(template.getStudent().getId()))) { + throw new CustomException(ErrorCode.FACE_SESSION_NOT_FOUND); + } + Map templateRevisions = templateRevisions(templates); + return new CandidateRequestSnapshot(sessionRequest(templates), templateRevisions); + } + + private Map candidateTemplateRevisions(Set studentIds) { + return templateRevisions(candidateTemplates(studentIds)); + } + + private List candidateTemplates(Set studentIds) { List templates = faceTemplateRepository.findAllByStudent_IdIn(studentIds).stream() .filter(template -> eligibleStudent(template.getStudent())) .toList(); if (templates.size() != studentIds.size() || templates.size() > properties.maxCandidates() || templates.isEmpty()) { throw new CustomException(ErrorCode.FACE_SESSION_NOT_FOUND); } - return sessionRequest(templates); + return templates; + } + + private static Map templateRevisions(List templates) { + return templates.stream().collect(Collectors.toUnmodifiableMap( + template -> template.getStudent().getId(), FaceTemplate::getRevision)); } private static AiFaceModel modelOf(FaceTemplate template) { @@ -506,4 +571,7 @@ private static MediaType frameMediaType(String value) { default -> throw new CustomException(ErrorCode.FACE_INVALID_MEDIA); }; } + + private record CandidateRequestSnapshot(AiFaceSessionRequest request, Map templateRevisions) { + } } diff --git a/src/main/resources/db/migration/V10__add_face_template_revision.sql b/src/main/resources/db/migration/V10__add_face_template_revision.sql new file mode 100644 index 00000000..6e425846 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_face_template_revision.sql @@ -0,0 +1,2 @@ +ALTER TABLE face_template + ADD COLUMN revision BIGINT NOT NULL DEFAULT 0; diff --git a/src/test/java/com/checkup/checkup/domain/face/controller/FacePayloadLimitFilterTest.java b/src/test/java/com/checkup/checkup/domain/face/controller/FacePayloadLimitFilterTest.java index 9b1f92d0..4176b7de 100644 --- a/src/test/java/com/checkup/checkup/domain/face/controller/FacePayloadLimitFilterTest.java +++ b/src/test/java/com/checkup/checkup/domain/face/controller/FacePayloadLimitFilterTest.java @@ -45,10 +45,10 @@ void tearDown() { } @Test - @DisplayName("등록할 수 없는 학생의 요청은 본문을 받지 않고 바로 거절한다") - void notEnrollableIsRejectedBeforeReadingBody() throws Exception { - willThrow(new CustomException(ErrorCode.FACE_ALREADY_REGISTERED)) - .given(enrollmentService).verifyEnrollable(MEMBER_ID); + @DisplayName("필수 동의가 없는 학생의 요청은 본문을 받지 않고 바로 거절한다") + void missingConsentIsRejectedBeforeReadingBody() throws Exception { + willThrow(new CustomException(ErrorCode.FACE_CONSENT_REQUIRED)) + .given(enrollmentService).verifyEnrollmentAllowed(MEMBER_ID); FacePayloadLimitFilter filter = filter(1, Duration.ofSeconds(1)); AtomicInteger handled = new AtomicInteger(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -56,8 +56,8 @@ void notEnrollableIsRejectedBeforeReadingBody() throws Exception { login(); filter.doFilter(request(ENROLLMENT_PATH), response, (req, res) -> handled.incrementAndGet()); - assertThat(response.getStatus()).isEqualTo(409); - assertThat(response.getContentAsString()).contains("FACE_ALREADY_REGISTERED"); + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getContentAsString()).contains("FACE_CONSENT_REQUIRED"); assertThat(handled).hasValue(0); } diff --git a/src/test/java/com/checkup/checkup/domain/face/service/FaceEnrollmentServiceTest.java b/src/test/java/com/checkup/checkup/domain/face/service/FaceEnrollmentServiceTest.java index 4788fcd1..ad9d9edb 100644 --- a/src/test/java/com/checkup/checkup/domain/face/service/FaceEnrollmentServiceTest.java +++ b/src/test/java/com/checkup/checkup/domain/face/service/FaceEnrollmentServiceTest.java @@ -30,15 +30,17 @@ */ class FaceEnrollmentServiceTest { private static final Long MEMBER_ID = 7L; + private static final Long STUDENT_ID = 40L; private static final AiFaceModel MODEL = new AiFaceModel("model-a", "v1", 256, "l2"); private final FaceEnrollmentStore enrollmentStore = mock(FaceEnrollmentStore.class); private final AiFaceClient aiFaceClient = mock(AiFaceClient.class); + private final FaceRecognitionService faceRecognitionService = mock(FaceRecognitionService.class); private final FaceProperties properties = new FaceProperties( "http://face-ai.test", "secret", Duration.ofSeconds(2), Duration.ofSeconds(30), 1024, 512, Duration.ofMillis(200), 2, 2, Duration.ofSeconds(1), Duration.ofMinutes(5), 60_000, "v1"); private final FaceEnrollmentService service = new FaceEnrollmentService( - enrollmentStore, aiFaceClient, properties, JsonMapper.builder().build()); + enrollmentStore, aiFaceClient, faceRecognitionService, properties, JsonMapper.builder().build()); @Test @DisplayName("동의가 없으면 AI를 호출하지 않는다") @@ -54,16 +56,20 @@ void withoutConsentDoesNotCallAi() { } @Test - @DisplayName("기존 등록이 있으면 AI를 호출하지 않는다") - void alreadyEnrolledDoesNotCallAi() { + @DisplayName("기존 등록이 있어도 새 영상을 추출해 재등록한다") + void alreadyEnrolledCanBeReplaced() { given(enrollmentStore.status(MEMBER_ID)) .willReturn(new FaceEnrollmentStore.FaceStatusResponseData(true, true, true)); + List> vectors = List.of(Collections.nCopies(256, 0.0625)); + given(aiFaceClient.extract(any(), eq(MediaType.parseMediaType("video/webm")))) + .willReturn(new AiFaceEnrollmentResponse(MODEL, 2, 1, vectors)); + given(enrollmentStore.saveOrReplaceTemplate(eq(MEMBER_ID), eq(MODEL), any())).willReturn(STUDENT_ID); - assertThatThrownBy(() -> service.enroll(MEMBER_ID, "video/webm", video())) - .isInstanceOfSatisfying(CustomException.class, - e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FACE_ALREADY_REGISTERED)); + var response = service.enroll(MEMBER_ID, "video/webm", video()); - verify(aiFaceClient, never()).extract(any(), any()); + assertThat(response.status()).isEqualTo("REGISTERED"); + verify(enrollmentStore).saveOrReplaceTemplate(eq(MEMBER_ID), eq(MODEL), any()); + verify(faceRecognitionService).refreshStudentTemplate(STUDENT_ID); } @Test @@ -80,19 +86,37 @@ void nonStudentDoesNotCallAi() { } @Test - @DisplayName("성공은 벡터와 모델정보를 저장하고 브라우저에는 상태만 준다") + @DisplayName("초기 등록 성공 시 벡터만 저장하고 활성 세션 갱신을 요청한다") void successStoresVectorsAndReturnsStatusOnly() { given(enrollmentStore.status(MEMBER_ID)) .willReturn(new FaceEnrollmentStore.FaceStatusResponseData(true, true, false)); List> vectors = List.of(Collections.nCopies(256, 0.0625)); given(aiFaceClient.extract(any(), eq(MediaType.parseMediaType("video/webm")))) .willReturn(new AiFaceEnrollmentResponse(MODEL, 2, 1, vectors)); + given(enrollmentStore.saveOrReplaceTemplate(eq(MEMBER_ID), eq(MODEL), any())).willReturn(STUDENT_ID); var response = service.enroll(MEMBER_ID, "video/webm", video()); assertThat(response.status()).isEqualTo("REGISTERED"); - verify(enrollmentStore).saveTemplate(eq(MEMBER_ID), eq(MODEL), + verify(enrollmentStore).saveOrReplaceTemplate(eq(MEMBER_ID), eq(MODEL), argThat(json -> json.startsWith("[[0.0625,0.0625") && json.endsWith("]]"))); + verify(faceRecognitionService).refreshStudentTemplate(STUDENT_ID); + } + + @Test + @DisplayName("AI가 새 템플릿을 만들지 못하면 기존 템플릿과 활성 세션을 건드리지 않는다") + void invalidAiExtractionLeavesExistingTemplateUntouched() { + given(enrollmentStore.status(MEMBER_ID)) + .willReturn(new FaceEnrollmentStore.FaceStatusResponseData(true, true, true)); + given(aiFaceClient.extract(any(), eq(MediaType.parseMediaType("video/webm")))) + .willReturn(new AiFaceEnrollmentResponse(MODEL, 2, 1, List.of())); + + assertThatThrownBy(() -> service.enroll(MEMBER_ID, "video/webm", video())) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FACE_AI_BAD_GATEWAY)); + + verify(enrollmentStore, never()).saveOrReplaceTemplate(any(), any(), any()); + verify(faceRecognitionService, never()).refreshStudentTemplate(any()); } @Test 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 ddb952aa..800182d5 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 @@ -16,11 +16,14 @@ import com.checkup.checkup.domain.attendance.entity.AttendancePurpose; import com.checkup.checkup.domain.attendance.entity.AttendanceRecordResult; import com.checkup.checkup.domain.attendance.service.AttendanceService; -import com.checkup.checkup.domain.face.entity.FaceRecognitionResult; +import com.checkup.checkup.domain.face.ai.AiFaceCandidate; import com.checkup.checkup.domain.face.ai.AiFaceClient; import com.checkup.checkup.domain.face.ai.AiFaceException; import com.checkup.checkup.domain.face.ai.AiFaceFrameResponse; +import com.checkup.checkup.domain.face.ai.AiFaceModel; +import com.checkup.checkup.domain.face.ai.AiFaceSessionRequest; import com.checkup.checkup.domain.face.config.FaceProperties; +import com.checkup.checkup.domain.face.entity.FaceRecognitionResult; import com.checkup.checkup.domain.face.entity.FaceTemplate; import com.checkup.checkup.domain.member.entity.Student; import com.checkup.checkup.domain.member.repository.StudentRepository; @@ -30,9 +33,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.time.Duration; import java.time.Instant; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Set; @@ -46,6 +51,8 @@ class FaceRecognitionServiceTest { private static final Long ADMIN_ID = 12L; private static final Long STUDENT_DB_ID = 40L; private static final Long DATAGSM_STUDENT_ID = 900L; + private static final Long OTHER_STUDENT_DB_ID = 41L; + private static final Long OTHER_DATAGSM_STUDENT_ID = 901L; private static final UUID SESSION_ID = UUID.randomUUID(); private static final Instant NOW = Instant.parse("2026-09-29T12:00:00Z"); @@ -72,6 +79,67 @@ void sessionStaysActiveDuringAiCall() { given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true); } + @Test + @DisplayName("재등록된 학생의 벡터로 활성 AI 세션 전체 후보를 갱신한다") + void refreshesActiveSessionWithCurrentCandidates() { + FaceSessionView session = session(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID)); + FaceTemplate refreshedTemplate = template(STUDENT_DB_ID, DATAGSM_STUDENT_ID, vectorJson(0)); + 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))) + .willReturn(List.of(refreshedTemplate, otherTemplate)); + service.refreshStudentTemplate(STUDENT_DB_ID); + + ArgumentCaptor request = ArgumentCaptor.forClass(AiFaceSessionRequest.class); + verify(aiFaceClient).createSession(eq(SESSION_ID), request.capture()); + assertThat(request.getValue().candidates()).extracting(AiFaceCandidate::studentId) + .containsExactlyInAnyOrder("900", "901"); + assertThat(request.getValue().candidates().stream() + .filter(candidate -> candidate.studentId().equals("900")) + .findFirst().orElseThrow().vectors().getFirst()).containsExactlyElementsOf(vectorValues(0)); + } + + @Test + @DisplayName("같은 세션의 다른 학생도 동시에 재등록되면 최신 전체 후보로 다시 갱신한다") + void retriesWhenAnotherCandidateChangesDuringRefresh() { + FaceSessionView session = session(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID)); + FaceTemplate target = versionedTemplate(STUDENT_DB_ID, DATAGSM_STUDENT_ID, 0L, vectorJson(0)); + FaceTemplate otherBefore = versionedTemplate(OTHER_STUDENT_DB_ID, OTHER_DATAGSM_STUDENT_ID, 0L, vectorJson(1)); + 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))) + .willReturn(List.of(target, otherBefore), List.of(target, otherAfter)); + + service.refreshStudentTemplate(STUDENT_DB_ID); + + ArgumentCaptor requests = ArgumentCaptor.forClass(AiFaceSessionRequest.class); + verify(aiFaceClient, times(2)).createSession(eq(SESSION_ID), requests.capture()); + assertThat(requests.getAllValues().get(1).candidates().stream() + .filter(candidate -> candidate.studentId().equals("901")) + .findFirst().orElseThrow().vectors().getFirst()).containsExactlyElementsOf(vectorValues(2)); + } + + @Test + @DisplayName("AI 세션 갱신이 실패하면 오래된 템플릿을 쓰지 않도록 세션을 닫는다") + void closesSessionWhenTemplateRefreshFails() { + FaceSessionView session = session(Set.of(STUDENT_DB_ID)); + 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))) + .willReturn(List.of(refreshedTemplate)); + willThrow(new IllegalStateException("AI unavailable")) + .given(aiFaceClient).createSession(eq(SESSION_ID), any()); + + service.refreshStudentTemplate(STUDENT_DB_ID); + + verify(sessionStore).markInactive(SESSION_ID, ADMIN_ID); + verify(aiFaceClient).deleteSession(SESSION_ID); + verify(sessionStore).delete(SESSION_ID); + } + @Test @DisplayName("KNOWN 후보만 현재 출석 대상으로 기록한다") void recordsOnlyKnownCandidates() { @@ -442,6 +510,44 @@ private static FaceSessionView session(Set candidates) { NOW.minusSeconds(2), true, candidates); } + private static FaceTemplate template(Long studentId, Long dataGsmStudentId, String vectorsJson) { + Student student = mock(Student.class); + given(student.getId()).willReturn(studentId); + given(student.getDatagsmStudentId()).willReturn(dataGsmStudentId); + given(student.isAttendanceEligible()).willReturn(true); + return FaceTemplate.create(student, new AiFaceModel("model-a", "v1", 256, "l2"), vectorsJson); + } + + private static FaceTemplate versionedTemplate( + Long studentId, Long dataGsmStudentId, long revision, String vectorsJson + ) { + Student student = mock(Student.class); + given(student.getId()).willReturn(studentId); + given(student.getDatagsmStudentId()).willReturn(dataGsmStudentId); + given(student.isAttendanceEligible()).willReturn(true); + FaceTemplate template = mock(FaceTemplate.class); + given(template.getStudent()).willReturn(student); + given(template.getRevision()).willReturn(revision); + given(template.getModelId()).willReturn("model-a"); + given(template.getModelVersion()).willReturn("v1"); + given(template.getDimension()).willReturn(256); + given(template.getNormalization()).willReturn("l2"); + given(template.getVectorsJson()).willReturn(vectorsJson); + return template; + } + + private static String vectorJson(int hotIndex) { + return "[[" + vectorValues(hotIndex).stream() + .map(String::valueOf) + .collect(java.util.stream.Collectors.joining(",")) + "]]"; + } + + private static List vectorValues(int hotIndex) { + List vector = new java.util.ArrayList<>(Collections.nCopies(256, 0.0)); + vector.set(hotIndex, 1.0); + return vector; + } + private static AiFaceFrameResponse frame(String status, String studentId) { return new AiFaceFrameResponse("frame-1", List.of(face(status, studentId))); } From 3d11445ca4a48c98bbc31abcf3e6cb1995052084 Mon Sep 17 00:00:00 2001 From: zizon-seoha Date: Mon, 12 Oct 2026 00:04:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(ci):=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=EC=99=80=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B2=88=ED=98=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- settings.gradle | 7 +++++++ ...te_revision.sql => V19__add_face_template_revision.sql} | 0 2 files changed, 7 insertions(+) rename src/main/resources/db/migration/{V10__add_face_template_revision.sql => V19__add_face_template_revision.sql} (100%) diff --git a/settings.gradle b/settings.gradle index acfa912d..b31c0d29 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,8 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + rootProject.name = 'checkup' diff --git a/src/main/resources/db/migration/V10__add_face_template_revision.sql b/src/main/resources/db/migration/V19__add_face_template_revision.sql similarity index 100% rename from src/main/resources/db/migration/V10__add_face_template_revision.sql rename to src/main/resources/db/migration/V19__add_face_template_revision.sql