From faaa78f59b5d38a4ce8f508bc34d045a4748017f Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:39:23 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=EC=96=BC=EA=B5=B4=20=EC=9D=B8?= =?UTF-8?q?=EC=8B=9D=20=EC=B8=B5=20=EC=98=A4=EB=A5=98=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20INVALID=5FFLOOR=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/checkup/checkup/global/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) 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, "인식 대상 학생이 허용 인원을 초과했습니다."), From 3fe8dbb5805c6504106e08c4d548e579dac1cead Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:39:23 +0900 Subject: [PATCH 02/13] =?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=EC=97=90=20=EC=B8=B5=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/db/migration/V21__add_face_session_floor.sql | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/main/resources/db/migration/V21__add_face_session_floor.sql 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); From dc04d0cba351600daa35b2740abe083df2c9d06f Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:40:01 +0900 Subject: [PATCH 03/13] =?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=EC=97=90=20=EC=B9=B4=EB=A9=94?= =?UTF-8?q?=EB=9D=BC=20=EC=B8=B5=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/face/entity/FaceRecognitionSession.java | 6 ++++++ .../domain/face/service/FaceSessionStore.java | 12 ++++++------ .../checkup/domain/face/service/FaceSessionView.java | 3 ++- 3 files changed, 14 insertions(+), 7 deletions(-) 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/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 From 063c393197ac9c37485981de3642c5a86e05f7cf Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:40:08 +0900 Subject: [PATCH 04/13] =?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=EC=83=9D=EC=84=B1=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=C2=B7=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=B8=B5=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 --- .../checkup/domain/face/dto/FaceSessionCreateRequest.java | 4 +++- .../checkup/checkup/domain/face/dto/FaceSessionResponse.java | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) 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 ) { } From 221aba7b6dcf269bbf897b9c1dd78aa52d017ba1 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:40:08 +0900 Subject: [PATCH 05/13] =?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=EC=83=9D=EC=84=B1=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B8=B5=20=EA=B2=80=EC=A6=9D=20=ED=9B=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FaceRecognitionController.java | 4 ++-- .../face/service/FaceRecognitionService.java | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) 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/service/FaceRecognitionService.java b/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java index 1bc27f97..9cb87283 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,7 +134,7 @@ public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose) } throw e; } - return new FaceSessionResponse(sessionId, purpose, "ACTIVE"); + return new FaceSessionResponse(sessionId, purpose, floor, "ACTIVE"); } /** From 5f60eec4b09ba641797d7642cea12b1a59a9a147 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:40:38 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=ED=94=84=EB=A0=88=EC=9E=84=20?= =?UTF-8?q?=EC=9D=B8=EC=8B=9D=20=EC=83=81=ED=83=9C=EC=97=90=20OTHER=5FFLOO?= =?UTF-8?q?R=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../checkup/checkup/domain/face/dto/FaceFrameResponse.java | 5 +++-- 1 file changed, 3 insertions(+), 2 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 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, From 292e9a7544fff957e967fc099c4589673d2fec02 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:40:38 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=B8=B5?= =?UTF-8?q?=EC=9D=B4=20=EC=95=84=EB=8B=8C=20=ED=95=99=EC=83=9D=EC=9D=84=20?= =?UTF-8?q?OTHER=5FFLOOR=EB=A1=9C=20=EC=95=8C=EB=A6=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../face/service/FaceRecognitionService.java | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 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 9cb87283..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 @@ -140,6 +140,7 @@ public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose, /** * 카메라 프레임 한 장에서 얼굴을 인식하고, 현재 세션 후보로 확인된 학생을 출석 처리한다. * AI가 후보 밖의 학생을 지목하면 출석으로 인정하지 않고 {@code UNKNOWN}으로 낮춘다. 결과는 최근 인식 기록에도 남긴다(#143). + * 세션 층이 아닌 학생은 이름·학번·출석 없이 {@code OTHER_FLOOR}로 알린다(#253). * 프레임 이미지는 처리 뒤 메모리에서 지우고 저장하지 않는다. * * @param frameIdHeader 프레임 id(선택, 최대 128자). 없으면 새로 만든다 @@ -338,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( @@ -364,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); } @@ -373,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()); } /** From 86bc62563e181bf9c91556b3c08756929b81064d Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:42:45 +0900 Subject: [PATCH 08/13] =?UTF-8?q?test:=20=EC=84=B8=EC=85=98=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=B8=B5=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=99=95=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/face/service/FaceSessionStoreTest.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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)); From 3cc870a5ac03156484c8b72c736c1c287db05fab Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:42:46 +0900 Subject: [PATCH 09/13] =?UTF-8?q?test:=20=EC=96=BC=EA=B5=B4=20=EC=9D=B8?= =?UTF-8?q?=EC=8B=9D=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B8=B5=20=EC=9E=88=EB=8A=94=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=EC=97=90=20=EB=A7=9E=EC=B6=94=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FaceRecognitionLogServiceTest.java | 2 +- .../face/service/FaceRecognitionServiceTest.java | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) 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..120f1c0a 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,12 +344,12 @@ 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()); } @@ -511,6 +512,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 +523,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); } From 7625f73797de52419a39096c94ee64c29527beda Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:43:04 +0900 Subject: [PATCH 10/13] =?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=20OTHER=5FFLOOR=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=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 | 21 +++++++++++++++++++ 1 file changed, 21 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 120f1c0a..6814a72a 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 @@ -495,6 +495,27 @@ 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); + } + /** 현재 세션 후보가 학생 한 명이고, 프레임을 보내면 AI가 주어진 결과를 돌려주게 한다. */ private FaceSessionView givenFrame(AiFaceFrameResponse frame) { FaceSessionView session = session(Set.of(STUDENT_DB_ID)); From 3bbf7cfc6ef930016d7d865a3d80b54a443ca2cc Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:43:20 +0900 Subject: [PATCH 11/13] =?UTF-8?q?test:=20=EC=B8=B5=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=EC=84=B8=EC=85=98=EC=9D=80=20=EC=B8=B5?= =?UTF-8?q?=EC=9D=84=20=EB=94=B0=EC=A7=80=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=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 | 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 6814a72a..424b6c16 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 @@ -516,6 +516,25 @@ void otherFloorStudentIsReportedWithoutIdentity() { 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)); From d9986f0957c896a504d8ca72829daf98b1b9ae1f Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:43:46 +0900 Subject: [PATCH 12/13] =?UTF-8?q?test:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EC=B8=B5=EC=9C=BC=EB=A1=9C=20=EC=84=B8=EC=85=98=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B1=B0=EB=B6=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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/service/FaceRecognitionServiceTest.java | 14 ++++++++++++++ 1 file changed, 14 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 424b6c16..2008b222 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 @@ -353,6 +353,20 @@ void noEligibleTemplateIsRejected() { 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("후보 상한을 따로 정하지 않으면 기본 200명이다") void defaultMaximumIs200() { From 8122873387cd4b33d402aec617bb13f92a1453fe Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Thu, 22 Oct 2026 20:43:46 +0900 Subject: [PATCH 13/13] =?UTF-8?q?test:=20=EC=84=B8=EC=85=98=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=95=8C=20=EC=B8=B5=20=EC=A0=80=EC=9E=A5=C2=B7?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=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 --- .../face/service/FaceRecognitionServiceTest.java | 14 ++++++++++++++ 1 file changed, 14 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 2008b222..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 @@ -367,6 +367,20 @@ void invalidFloorIsRejected(Integer 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() {