Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}

rootProject.name = 'checkup'
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,18 +48,27 @@ 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;

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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,6 +18,10 @@ public interface FaceTemplateRepository extends JpaRepository<FaceTemplate, Long

Optional<FaceTemplate> findByStudent_Id(Long studentId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT template FROM FaceTemplate template WHERE template.student.id = :studentId")
Optional<FaceTemplate> findForUpdateByStudentId(@Param("studentId") Long studentId);

@EntityGraph(attributePaths = "student")
List<FaceTemplate> findAllByOrderByStudent_IdAsc();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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).
Expand All @@ -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);
}
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -89,20 +92,16 @@ 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);
}
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,28 @@ 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);
}
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();
}

/** 학생의 얼굴 템플릿을 지운다. 졸업·자퇴 처리에서 쓰며, 호출한 쪽 트랜잭션과 상관없이 지우도록 새 트랜잭션에서 한다. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Long, Long> currentRevisions = candidateTemplateRevisions(session.candidateStudentIds());
if (currentRevisions.equals(snapshot.templateRevisions())) {
return;
}
}
throw new IllegalStateException("Face template changed repeatedly during session refresh");
}

/**
* 유휴 시간이 지났고 처리 중인 프레임이 없는 세션을 닫는다. 정리 작업이 주기적으로 부른다.
* AI 쪽 삭제가 실패해 비활성 상태로 남은 세션도 여기서 다시 정리한다.
Expand Down Expand Up @@ -415,13 +457,36 @@ private static boolean eligibleStudent(Student student) {
}

private AiFaceSessionRequest candidateRequest(Set<Long> studentIds) {
return candidateRequestSnapshot(studentIds, null).request();
}

private CandidateRequestSnapshot candidateRequestSnapshot(Set<Long> studentIds, Long refreshedStudentId) {
List<FaceTemplate> templates = candidateTemplates(studentIds);
if (refreshedStudentId != null && templates.stream()
.noneMatch(template -> refreshedStudentId.equals(template.getStudent().getId()))) {
throw new CustomException(ErrorCode.FACE_SESSION_NOT_FOUND);
}
Map<Long, Long> templateRevisions = templateRevisions(templates);
return new CandidateRequestSnapshot(sessionRequest(templates), templateRevisions);
}

private Map<Long, Long> candidateTemplateRevisions(Set<Long> studentIds) {
return templateRevisions(candidateTemplates(studentIds));
}

private List<FaceTemplate> candidateTemplates(Set<Long> studentIds) {
List<FaceTemplate> 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<Long, Long> templateRevisions(List<FaceTemplate> templates) {
return templates.stream().collect(Collectors.toUnmodifiableMap(
template -> template.getStudent().getId(), FaceTemplate::getRevision));
}

private static AiFaceModel modelOf(FaceTemplate template) {
Expand Down Expand Up @@ -506,4 +571,7 @@ private static MediaType frameMediaType(String value) {
default -> throw new CustomException(ErrorCode.FACE_INVALID_MEDIA);
};
}

private record CandidateRequestSnapshot(AiFaceSessionRequest request, Map<Long, Long> templateRevisions) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE face_template
ADD COLUMN revision BIGINT NOT NULL DEFAULT 0;
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,19 @@ 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();

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);
}

Expand Down
Loading
Loading