From c833818abb390c2e3c860bd108dd1b40bf1df055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:45:15 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=9A=A9=EB=8F=84=EB=B3=84=20?= =?UTF-8?q?=EC=82=B4=EC=95=84=20=EC=9E=88=EB=8A=94=20QR=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qr/repository/QrSessionRepository.java | 25 +++++++++++++++++-- .../domain/qr/service/QrSessionService.java | 7 ++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/checkup/checkup/domain/qr/repository/QrSessionRepository.java b/src/main/java/com/checkup/checkup/domain/qr/repository/QrSessionRepository.java index 0a1fb878..9fda06f8 100644 --- a/src/main/java/com/checkup/checkup/domain/qr/repository/QrSessionRepository.java +++ b/src/main/java/com/checkup/checkup/domain/qr/repository/QrSessionRepository.java @@ -26,6 +26,7 @@ * - {@code qr:session:{id}}: 세션 해시 * - {@code qr:token:{token}}: 토큰 해시(세션 ID, 만료 시각) * - {@code qr:admin:{adminId}}: 관리자가 만든 세션 ID 집합 + * - {@code qr:purpose:{purpose}}: 용도별 세션 ID와 lease 만료 시각(epoch ms)의 정렬 집합. 학생에게 지금 출석을 받는지 알려 줄 때 쓴다 * * 키가 TTL보다 먼저 사라지면 세션은 종료된 것으로, 토큰은 발급하지 않은 것으로 판정되므로 * Redis는 메모리 축출이 없는 {@code maxmemory-policy noeviction}으로 운영한다. @@ -37,17 +38,21 @@ public class QrSessionRepository { private static final String SESSION_KEY = "qr:session:"; private static final String TOKEN_KEY = "qr:token:"; private static final String ADMIN_KEY = "qr:admin:"; + private static final String PURPOSE_KEY = "qr:purpose:"; private static final RedisScript SAVE_IF_PRESENT = new DefaultRedisScript<>(""" if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end - for i = 3, #ARGV, 2 do + for i = 4, #ARGV, 2 do redis.call('HSET', KEYS[1], ARGV[i], ARGV[i + 1]) end redis.call('PEXPIRE', KEYS[1], ARGV[1]) redis.call('SADD', KEYS[2], ARGV[2]) redis.call('PEXPIRE', KEYS[2], ARGV[1]) + redis.call('ZREMRANGEBYSCORE', KEYS[3], '-inf', '(' .. (tonumber(ARGV[3]) - tonumber(ARGV[1]))) + redis.call('ZADD', KEYS[3], ARGV[3], ARGV[2]) + redis.call('PEXPIRE', KEYS[3], ARGV[1]) return 1 """, Long.class); @@ -90,6 +95,10 @@ public void save(QrSession session, Duration ttl) { String adminKey = ADMIN_KEY + session.adminId(); redisTemplate.opsForSet().add(adminKey, session.id()); redisTemplate.expire(adminKey, ttl); + + String purposeKey = PURPOSE_KEY + session.purpose(); + redisTemplate.opsForZSet().add(purposeKey, session.id(), session.leaseExpiresAt().toEpochMilli()); + redisTemplate.expire(purposeKey, ttl); } /** @@ -104,13 +113,14 @@ public boolean saveIfPresent(QrSession session, Duration ttl) { List args = new ArrayList<>(); args.add(String.valueOf(ttl.toMillis())); args.add(session.id()); + args.add(String.valueOf(session.leaseExpiresAt().toEpochMilli())); fields.forEach((field, value) -> { args.add(field); args.add(value); }); Long saved = redisTemplate.execute( SAVE_IF_PRESENT, - List.of(SESSION_KEY + session.id(), ADMIN_KEY + session.adminId()), + List.of(SESSION_KEY + session.id(), ADMIN_KEY + session.adminId(), PURPOSE_KEY + session.purpose()), args.toArray() ); return Long.valueOf(1).equals(saved); @@ -155,6 +165,17 @@ private static Map fields(QrSession session) { public void delete(QrSession session) { redisTemplate.delete(SESSION_KEY + session.id()); redisTemplate.opsForSet().remove(ADMIN_KEY + session.adminId(), session.id()); + redisTemplate.opsForZSet().remove(PURPOSE_KEY + session.purpose(), session.id()); + } + + /** + * 주어진 시각에 lease가 남은 그 용도의 세션이 하나라도 있는지 확인한다. + * 어느 관리자의 세션인지는 보지 않는다. 종료한 세션은 집합에서 빠지고, lease가 지난 세션은 점수로 걸러진다. + */ + public boolean existsActiveByPurpose(AttendancePurpose purpose, Instant now) { + Long count = redisTemplate.opsForZSet().count( + PURPOSE_KEY + purpose, now.toEpochMilli() + 1, Double.POSITIVE_INFINITY); + return count != null && count > 0; } /** diff --git a/src/main/java/com/checkup/checkup/domain/qr/service/QrSessionService.java b/src/main/java/com/checkup/checkup/domain/qr/service/QrSessionService.java index 4942467e..172a68a4 100644 --- a/src/main/java/com/checkup/checkup/domain/qr/service/QrSessionService.java +++ b/src/main/java/com/checkup/checkup/domain/qr/service/QrSessionService.java @@ -98,6 +98,13 @@ public void closeAll(Long adminId) { .forEach(sessionId -> close(adminId, sessionId)); } + /** + * 그 용도의 QR 세션이 하나라도 살아 있는지 확인한다. 어느 관리자의 세션인지는 묻지 않는다. + */ + public boolean isAccepting(AttendancePurpose purpose) { + return qrSessionRepository.existsActiveByPurpose(purpose, clock.instant()); + } + private boolean needsRotation(QrSession session, Instant now) { if (!session.operatingDay().equals(operatingDayCalculator.today())) { return true; From bbae937e51f474e9ce786929ad10b4e7a6e21f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:45:15 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=B8=B5=EB=B3=84=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=EC=96=BC=EA=B5=B4=20=EC=9D=B8=EC=8B=9D=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FaceRecognitionSessionRepository.java | 7 +++++++ .../face/service/FaceRecognitionService.java | 14 ++++++++++++++ .../domain/face/service/FaceSessionStore.java | 10 ++++++++++ 3 files changed, 31 insertions(+) diff --git a/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionRepository.java b/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionRepository.java index 243b63b4..b403265b 100644 --- a/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionRepository.java +++ b/src/main/java/com/checkup/checkup/domain/face/repository/FaceRecognitionSessionRepository.java @@ -1,5 +1,6 @@ package com.checkup.checkup.domain.face.repository; +import com.checkup.checkup.domain.attendance.entity.AttendancePurpose; import com.checkup.checkup.domain.face.entity.FaceRecognitionSession; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -23,6 +24,12 @@ public interface FaceRecognitionSessionRepository extends JpaRepository findAllByIdIn(List ids); + @Query("SELECT COUNT(s) > 0 FROM FaceRecognitionSession s WHERE s.active = true " + + "AND s.purpose = :purpose AND s.lastActivityAt > :cutoff " + + "AND (s.floor IS NULL OR s.floor = :floor)") + boolean existsActiveOnFloor(@Param("purpose") AttendancePurpose purpose, @Param("floor") Integer floor, + @Param("cutoff") Instant cutoff); + @Modifying @Query("UPDATE FaceRecognitionSession s SET s.lastActivityAt = :now, " + "s.frameLockToken = :token, s.frameLockUntil = :lockUntil " + 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 108e40bc..dea2fa30 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 @@ -315,6 +315,20 @@ private void refreshSessionTemplate(FaceSessionView session, Long studentId) { throw new IllegalStateException("Face template changed repeatedly during session refresh"); } + /** + * 그 층 학생을 지금 얼굴로 출석 받는 세션이 있는지 확인한다. 유휴 시간이 지난 세션은 정리 전이어도 세지 않는다. + * 층이 없는(호실 미배정) 학생은 어느 카메라의 후보도 아니므로 false다. + * + * @param floor 학생 호실의 층. 호실이 없으면 null + */ + public boolean isAccepting(AttendancePurpose purpose, Integer floor) { + if (floor == null) { + return false; + } + Instant cutoff = clock.instant().minus(properties.sessionIdleTimeout()); + return faceSessionStore.existsActiveOnFloor(purpose, floor, cutoff); + } + /** * 유휴 시간이 지났고 처리 중인 프레임이 없는 세션을 닫는다. 정리 작업이 주기적으로 부른다. * AI 쪽 삭제가 실패해 비활성 상태로 남은 세션도 여기서 다시 정리한다. 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 2970cdc9..dd687b28 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 @@ -43,6 +43,16 @@ public void create(UUID sessionId, Long adminMemberId, AttendancePurpose purpose } } + /** + * 그 용도·층을 맡은 활성 세션이 하나라도 있는지 확인한다. 층 도입 전에 만든 층 없는 세션은 모든 층으로 센다. + * + * @param cutoff 이 시각 이후에 활동이 있어야 살아 있는 세션으로 본다 + */ + @Transactional(readOnly = true) + public boolean existsActiveOnFloor(AttendancePurpose purpose, int floor, Instant cutoff) { + return sessionRepository.existsActiveOnFloor(purpose, floor, cutoff); + } + /** * 관리자 본인의 활성 세션을 후보 학생 id와 함께 읽는다. * From 296d332a04a96b4fb09d48fd1e6960c4181f6f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:45:15 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=A7=80=EA=B8=88=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=EC=9D=84=20=EB=B0=9B=EB=8A=94=20=EC=A4=91=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20API=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 --- .../controller/AttendanceController.java | 17 ++++++- .../response/AttendanceActiveResponse.java | 15 ++++++ .../service/AttendanceActiveService.java | 50 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/checkup/checkup/domain/attendance/dto/response/AttendanceActiveResponse.java create mode 100644 src/main/java/com/checkup/checkup/domain/attendance/service/AttendanceActiveService.java diff --git a/src/main/java/com/checkup/checkup/domain/attendance/controller/AttendanceController.java b/src/main/java/com/checkup/checkup/domain/attendance/controller/AttendanceController.java index 7ea34a25..1d8a67d8 100644 --- a/src/main/java/com/checkup/checkup/domain/attendance/controller/AttendanceController.java +++ b/src/main/java/com/checkup/checkup/domain/attendance/controller/AttendanceController.java @@ -1,6 +1,8 @@ package com.checkup.checkup.domain.attendance.controller; +import com.checkup.checkup.domain.attendance.dto.response.AttendanceActiveResponse; import com.checkup.checkup.domain.attendance.dto.response.MyAttendanceResponse; +import com.checkup.checkup.domain.attendance.service.AttendanceActiveService; import com.checkup.checkup.domain.attendance.service.AttendanceService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -12,14 +14,16 @@ /** * 학생 본인의 출석 조회 API. 로그인한 학생의 출석만 다루며 다른 학생 id는 받지 않는다. + * 지금 출석을 받는 중인지도 여기서 알려 준다. */ -@Tag(name = "출석", description = "학생 본인의 오늘 출석 상태") +@Tag(name = "출석", description = "학생 본인의 오늘 출석 상태와 지금 출석을 받는 중인지") @RestController @RequestMapping("/api/v1/attendance") @RequiredArgsConstructor public class AttendanceController { private final AttendanceService attendanceService; + private final AttendanceActiveService attendanceActiveService; /** * 본인의 오늘 운영일 출석 상태를 기숙사 입소·자습실로 나눠 조회한다. @@ -31,4 +35,15 @@ public class AttendanceController { public MyAttendanceResponse getMyAttendance(@AuthenticationPrincipal Long memberId) { return attendanceService.getMyToday(memberId); } + + /** + * 지금 용도별로 출석을 받는 중인지 조회한다. + * + * @param memberId 세션의 회원 id + */ + @Operation(summary = "지금 출석을 받는 중인지", description = "용도별로 지금 출석을 받는 세션이 하나라도 있으면 true다. QR은 그 용도의 살아 있는 QR 세션, 얼굴은 그 용도로 학생 본인 층을 맡은 활성 얼굴 인식 세션을 센다. 호실이 없는 학생은 얼굴 세션을 세지 않는다. 세션 id·관리자·토큰은 주지 않는다. 출석 시간표는 없다. 학생이 아니면 403 MISSING_STUDENT_INFO.") + @GetMapping("/active") + public AttendanceActiveResponse getActive(@AuthenticationPrincipal Long memberId) { + return attendanceActiveService.getActive(memberId); + } } diff --git a/src/main/java/com/checkup/checkup/domain/attendance/dto/response/AttendanceActiveResponse.java b/src/main/java/com/checkup/checkup/domain/attendance/dto/response/AttendanceActiveResponse.java new file mode 100644 index 00000000..6f84e302 --- /dev/null +++ b/src/main/java/com/checkup/checkup/domain/attendance/dto/response/AttendanceActiveResponse.java @@ -0,0 +1,15 @@ +package com.checkup.checkup.domain.attendance.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 지금 용도별로 출석을 받는 중인지. 어느 관리자·기기의 세션인지, 세션 id·토큰은 주지 않는다(#260). + * + * @param dormitory 기숙사 입소 출석을 받는 중인지 + * @param studyRoom 자습실 출석을 받는 중인지 + */ +public record AttendanceActiveResponse( + @Schema(description = "기숙사 입소(DORMITORY) 출석을 받는 중인지") boolean dormitory, + @Schema(description = "자습실(STUDY_ROOM) 출석을 받는 중인지") boolean studyRoom +) { +} diff --git a/src/main/java/com/checkup/checkup/domain/attendance/service/AttendanceActiveService.java b/src/main/java/com/checkup/checkup/domain/attendance/service/AttendanceActiveService.java new file mode 100644 index 00000000..9f2dff3e --- /dev/null +++ b/src/main/java/com/checkup/checkup/domain/attendance/service/AttendanceActiveService.java @@ -0,0 +1,50 @@ +package com.checkup.checkup.domain.attendance.service; + +import org.springframework.stereotype.Service; + +import com.checkup.checkup.domain.attendance.dto.response.AttendanceActiveResponse; +import com.checkup.checkup.domain.attendance.entity.AttendancePurpose; +import com.checkup.checkup.domain.face.service.FaceRecognitionService; +import com.checkup.checkup.domain.member.entity.Student; +import com.checkup.checkup.domain.member.repository.StudentRepository; +import com.checkup.checkup.domain.qr.service.QrSessionService; +import com.checkup.checkup.global.exception.CustomException; +import com.checkup.checkup.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +/** + * 학생에게 지금 출석을 받는 중인지 알려 준다(#260). 출석에는 시간표가 없고 관리자가 QR·얼굴 인식 페이지를 열어 둔 동안만 받는다(REQ-ATT-001). + * + * - QR: 그 용도의 살아 있는 QR 세션이 하나라도 있으면 받는 중이다. QR은 층을 가리지 않는다. + * - 얼굴: 그 용도로 학생 본인 층을 맡은 활성 얼굴 인식 세션이 있어야 한다. 다른 층 카메라는 이 학생을 인식하지 않는다(#256). + * + * {@link AttendanceService}는 얼굴 인식 서비스가 쓰므로, 순환 의존을 피하려고 따로 둔다. + */ +@Service +@RequiredArgsConstructor +public class AttendanceActiveService { + + private final StudentRepository studentRepository; + private final QrSessionService qrSessionService; + private final FaceRecognitionService faceRecognitionService; + + /** + * 로그인한 학생 기준으로 용도별 출석 접수 여부를 조회한다. 학생은 세션의 회원으로 정한다. + * + * @param memberId 세션의 회원 id + * @throws CustomException 학생이 아니면 {@link ErrorCode#MISSING_STUDENT_INFO}(403) + */ + public AttendanceActiveResponse getActive(Long memberId) { + Student student = studentRepository.findByMemberId(memberId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSING_STUDENT_INFO)); + Integer floor = student.getDormitoryFloor(); + return new AttendanceActiveResponse( + isAccepting(AttendancePurpose.DORMITORY, floor), + isAccepting(AttendancePurpose.STUDY_ROOM, floor)); + } + + private boolean isAccepting(AttendancePurpose purpose, Integer floor) { + return qrSessionService.isAccepting(purpose) || faceRecognitionService.isAccepting(purpose, floor); + } +} From cf601e8f8eeb21718a9168540ca0257621b69cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:01:11 +0900 Subject: [PATCH 4/7] =?UTF-8?q?test:=20=EC=9A=A9=EB=8F=84=EB=B3=84=20?= =?UTF-8?q?=EC=82=B4=EC=95=84=20=EC=9E=88=EB=8A=94=20QR=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A1=B0=ED=9A=8C=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 --- .../repository/QrSessionRepositoryTest.java | 59 ++++++++++++++++++- .../qr/service/QrSessionServiceTest.java | 23 ++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/checkup/checkup/domain/qr/repository/QrSessionRepositoryTest.java b/src/test/java/com/checkup/checkup/domain/qr/repository/QrSessionRepositoryTest.java index ef7532cd..9aa938db 100644 --- a/src/test/java/com/checkup/checkup/domain/qr/repository/QrSessionRepositoryTest.java +++ b/src/test/java/com/checkup/checkup/domain/qr/repository/QrSessionRepositoryTest.java @@ -20,7 +20,8 @@ import com.checkup.checkup.domain.qr.entity.QrToken; /** - * 실제 Redis에서 QR 세션 갱신이 종료된 세션을 되살리지 않는지, 토큰·세션 한 번 조회가 따로 읽은 값과 같은지 검증한다(#211). + * 실제 Redis에서 QR 세션 갱신이 종료된 세션을 되살리지 않는지, 토큰·세션 한 번 조회가 따로 읽은 값과 같은지, + * 용도별로 살아 있는 세션을 lease 기준으로 세는지 검증한다(#211, #260). */ @DataRedisTest @Import(QrSessionRepository.class) @@ -115,4 +116,60 @@ void closeAfterHeartbeatReadDoesNotRevive() { assertThat(qrSessionRepository.findById("session-1")).isEmpty(); assertThat(qrSessionRepository.findSessionIdsByAdmin(1L)).isEmpty(); } + + @Test + @DisplayName("lease가 남은 세션이 있으면 그 용도는 살아 있다") + void activeSessionCountsForItsPurpose() { + qrSessionRepository.save(session, TTL); + + assertThat(qrSessionRepository.existsActiveByPurpose(AttendancePurpose.DORMITORY, Instant.parse("2026-09-27T03:00:59Z"))).isTrue(); + assertThat(qrSessionRepository.existsActiveByPurpose(AttendancePurpose.STUDY_ROOM, Instant.parse("2026-09-27T03:00:59Z"))).isFalse(); + } + + @Test + @DisplayName("lease가 지났거나 종료한 세션은 세지 않는다") + void expiredOrClosedSessionDoesNotCount() { + qrSessionRepository.save(session, TTL); + + assertThat(qrSessionRepository.existsActiveByPurpose(AttendancePurpose.DORMITORY, Instant.parse("2026-09-27T03:01:00Z"))).isFalse(); + + qrSessionRepository.delete(session); + + assertThat(qrSessionRepository.existsActiveByPurpose(AttendancePurpose.DORMITORY, Instant.parse("2026-09-27T03:00:30Z"))).isFalse(); + } + + @Test + @DisplayName("heartbeat로 lease를 늘리면 늘어난 시각까지 살아 있다") + void heartbeatExtendsActiveLease() { + qrSessionRepository.save(session, TTL); + + qrSessionRepository.saveIfPresent(session.withLease(Instant.parse("2026-09-27T03:02:00Z")), TTL); + + assertThat(qrSessionRepository.existsActiveByPurpose(AttendancePurpose.DORMITORY, Instant.parse("2026-09-27T03:01:30Z"))).isTrue(); + } + + @Test + @DisplayName("다른 세션의 heartbeat는 lease가 남은 세션을 지우지 않는다") + void heartbeatKeepsOtherLiveSessions() { + QrSession other = new QrSession("session-2", 2L, AttendancePurpose.DORMITORY, session.operatingDay(), + "token-2", session.tokenExpiresAt(), Instant.parse("2026-09-27T03:01:30Z")); + qrSessionRepository.save(other, TTL); + qrSessionRepository.save(session, TTL); + + qrSessionRepository.saveIfPresent(session.withLease(Instant.parse("2026-09-27T03:02:00Z")), TTL); + qrSessionRepository.delete(session); + + assertThat(qrSessionRepository.existsActiveByPurpose(AttendancePurpose.DORMITORY, Instant.parse("2026-09-27T03:01:15Z"))).isTrue(); + } + + @Test + @DisplayName("close된 세션의 늦은 heartbeat는 살아 있는 세션으로 되살리지 않는다") + void lateHeartbeatAfterCloseDoesNotCount() { + qrSessionRepository.save(session, TTL); + qrSessionRepository.delete(session); + + qrSessionRepository.saveIfPresent(session.withLease(Instant.parse("2026-09-27T03:02:00Z")), TTL); + + assertThat(qrSessionRepository.existsActiveByPurpose(AttendancePurpose.DORMITORY, Instant.parse("2026-09-27T03:01:30Z"))).isFalse(); + } } diff --git a/src/test/java/com/checkup/checkup/domain/qr/service/QrSessionServiceTest.java b/src/test/java/com/checkup/checkup/domain/qr/service/QrSessionServiceTest.java index ed8878cd..b49e1d2e 100644 --- a/src/test/java/com/checkup/checkup/domain/qr/service/QrSessionServiceTest.java +++ b/src/test/java/com/checkup/checkup/domain/qr/service/QrSessionServiceTest.java @@ -258,4 +258,27 @@ private static Instant kst(int year, int month, int day, int hour, int minute, i .atZone(OperatingDayCalculator.ZONE) .toInstant(); } + + @Test + @DisplayName("세션이 열려 있는 동안만 그 용도의 출석을 받는 중이다") + void acceptingWhileSessionIsOpen() { + String sessionId = qrSessionService.create(ADMIN_A, AttendancePurpose.DORMITORY).sessionId(); + + assertThat(qrSessionService.isAccepting(AttendancePurpose.DORMITORY)).isTrue(); + assertThat(qrSessionService.isAccepting(AttendancePurpose.STUDY_ROOM)).isFalse(); + + qrSessionService.close(ADMIN_A, sessionId); + + assertThat(qrSessionService.isAccepting(AttendancePurpose.DORMITORY)).isFalse(); + } + + @Test + @DisplayName("heartbeat 없이 lease가 지나면 출석을 받지 않는다") + void notAcceptingAfterLeaseExpires() { + qrSessionService.create(ADMIN_A, AttendancePurpose.DORMITORY); + + clock.setInstant(clock.instant().plus(Duration.ofSeconds(60))); + + assertThat(qrSessionService.isAccepting(AttendancePurpose.DORMITORY)).isFalse(); + } } From 2d0a1fcb0f8d7820ef4f39fc8bf3abf3a901da2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:01:11 +0900 Subject: [PATCH 5/7] =?UTF-8?q?test:=20=EC=B8=B5=EB=B3=84=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=EC=96=BC=EA=B5=B4=20=EC=9D=B8=EC=8B=9D=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A1=B0=ED=9A=8C=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 --- .../service/FaceRecognitionServiceTest.java | 18 +++++++++++ .../face/service/FaceSessionStoreTest.java | 31 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) 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 40859092..57b0b000 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 @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; @@ -673,4 +674,21 @@ private static AiFaceFrameResponse.FaceResult face(String status, String student List.of(List.of(0.2, 0.3)), new AiFaceFrameResponse.Quality(0.8, 0.7, List.of()), new AiFaceFrameResponse.Recognition(status, studentId, 0.91, 0.12), 0, false); } + + @Test + @DisplayName("유휴 시간 안에 활동한 세션만 찾도록 기준 시각을 넘긴다") + void acceptingUsesIdleCutoff() { + given(sessionStore.existsActiveOnFloor(AttendancePurpose.DORMITORY, 3, NOW.minus(Duration.ofMinutes(5)))) + .willReturn(true); + + assertThat(service.isAccepting(AttendancePurpose.DORMITORY, 3)).isTrue(); + } + + @Test + @DisplayName("호실이 없어 층이 없으면 얼굴 출석을 받지 않는다") + void notAcceptingWithoutFloor() { + assertThat(service.isAccepting(AttendancePurpose.DORMITORY, null)).isFalse(); + + verify(sessionStore, never()).existsActiveOnFloor(any(), anyInt(), any()); + } } 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 bf0b6b0b..27117798 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 @@ -27,7 +27,7 @@ /** * 실제 PostgreSQL에서 얼굴 인식 세션 저장이 후보 학생을 한 번에 저장하고, - * 저장되지 않은 학생이 섞이면 세션째 취소되는지 검증한다(#216). + * 저장되지 않은 학생이 섞이면 세션째 취소되는지, 용도·층별 활성 세션을 찾는지 검증한다(#216, #260). */ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -106,4 +106,33 @@ void unknownStudentCancelsWholeSession() { "SELECT count(*) FROM face_recognition_session_candidate WHERE session_id = ?", Integer.class, sessionId)).isZero(); } + + @Test + @DisplayName("같은 용도·층의 활성 세션만 찾고 층 없는 세션은 모든 층으로 센다") + void findsActiveSessionByPurposeAndFloor() { + Instant recent = Instant.parse("2100-01-01T00:10:00Z"); + Instant cutoff = Instant.parse("2100-01-01T00:05:00Z"); + sessionStore.create(UUID.randomUUID(), adminMemberId, AttendancePurpose.DORMITORY, 3, studentIds, recent, recent); + + assertThat(sessionStore.existsActiveOnFloor(AttendancePurpose.DORMITORY, 3, cutoff)).isTrue(); + assertThat(sessionStore.existsActiveOnFloor(AttendancePurpose.DORMITORY, 4, cutoff)).isFalse(); + assertThat(sessionStore.existsActiveOnFloor(AttendancePurpose.STUDY_ROOM, 3, cutoff)).isFalse(); + + sessionStore.create(UUID.randomUUID(), adminMemberId, AttendancePurpose.STUDY_ROOM, null, studentIds, recent, recent); + + assertThat(sessionStore.existsActiveOnFloor(AttendancePurpose.STUDY_ROOM, 5, cutoff)).isTrue(); + } + + @Test + @DisplayName("유휴 시간이 지났거나 닫힌 세션은 찾지 않는다") + void ignoresIdleOrClosedSession() { + Instant cutoff = Instant.parse("2100-01-01T00:05:00Z"); + sessionStore.create(UUID.randomUUID(), adminMemberId, AttendancePurpose.DORMITORY, 3, studentIds, cutoff, cutoff); + UUID closed = UUID.randomUUID(); + Instant recent = Instant.parse("2100-01-01T00:10:00Z"); + sessionStore.create(closed, adminMemberId, AttendancePurpose.DORMITORY, 3, studentIds, recent, recent); + sessionStore.markInactive(closed, adminMemberId); + + assertThat(sessionStore.existsActiveOnFloor(AttendancePurpose.DORMITORY, 3, cutoff)).isFalse(); + } } From 618f4cde721124426a69fc1f7eb146e07558cb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:01:11 +0900 Subject: [PATCH 6/7] =?UTF-8?q?test:=20=EC=A7=80=EA=B8=88=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=EC=9D=84=20=EB=B0=9B=EB=8A=94=20=EC=A4=91=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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 --- .../controller/AttendanceControllerTest.java | 40 ++++++++- .../service/AttendanceActiveServiceTest.java | 82 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/checkup/checkup/domain/attendance/service/AttendanceActiveServiceTest.java diff --git a/src/test/java/com/checkup/checkup/domain/attendance/controller/AttendanceControllerTest.java b/src/test/java/com/checkup/checkup/domain/attendance/controller/AttendanceControllerTest.java index fe4cf46e..ca412d33 100644 --- a/src/test/java/com/checkup/checkup/domain/attendance/controller/AttendanceControllerTest.java +++ b/src/test/java/com/checkup/checkup/domain/attendance/controller/AttendanceControllerTest.java @@ -7,7 +7,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.checkup.checkup.domain.attendance.dto.response.AttendanceActiveResponse; import com.checkup.checkup.domain.attendance.dto.response.MyAttendanceResponse; +import com.checkup.checkup.domain.attendance.service.AttendanceActiveService; import com.checkup.checkup.domain.attendance.service.AttendanceService; import com.checkup.checkup.global.exception.CustomException; import com.checkup.checkup.global.exception.ErrorCode; @@ -28,7 +30,7 @@ /** * 본인 출석 조회 API가 로그인한 회원 id로 서비스를 부르고, 용도별 출석 상태를 JSON 필드로 응답하며 - * 미로그인·학생 아님 오류를 공통 형식으로 돌려주는지 검증한다. + * 미로그인·학생 아님 오류를 공통 형식으로 돌려주는지 검증한다. 지금 출석을 받는 중인지 조회도 함께 검증한다(#260). */ @WebMvcTest(AttendanceController.class) @Import({SecurityConfig.class, GlobalExceptionHandler.class}) @@ -36,6 +38,7 @@ class AttendanceControllerTest { private static final Long MEMBER_ID = 1L; private static final String ME = "/api/v1/attendance/me"; + private static final String ACTIVE = "/api/v1/attendance/active"; @Autowired private MockMvc mockMvc; @@ -43,6 +46,9 @@ class AttendanceControllerTest { @MockitoBean private AttendanceService attendanceService; + @MockitoBean + private AttendanceActiveService attendanceActiveService; + @Test @DisplayName("본인의 오늘 출석 상태를 운영일과 용도별 상태로 반환한다") void returnsMyAttendanceByPurpose() throws Exception { @@ -80,6 +86,38 @@ void withoutLoginReturnsUnauthorized() throws Exception { verifyNoInteractions(attendanceService); } + @Test + @DisplayName("용도별로 지금 출석을 받는 중인지만 반환한다") + void returnsActiveByPurpose() throws Exception { + given(attendanceActiveService.getActive(MEMBER_ID)).willReturn(new AttendanceActiveResponse(true, false)); + + mockMvc.perform(get(ACTIVE).with(loginAs(MEMBER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.dormitory").value(true)) + .andExpect(jsonPath("$.studyRoom").value(false)) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + @DisplayName("학생이 아니면 출석 접수 여부 조회도 403 MISSING_STUDENT_INFO를 반환한다") + void activeForNonStudentReturnsForbidden() throws Exception { + given(attendanceActiveService.getActive(MEMBER_ID)) + .willThrow(new CustomException(ErrorCode.MISSING_STUDENT_INFO)); + + mockMvc.perform(get(ACTIVE).with(loginAs(MEMBER_ID))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("MISSING_STUDENT_INFO")); + } + + @Test + @DisplayName("미인증 요청은 출석 접수 여부를 조회하지 않고 401을 반환한다") + void activeWithoutLoginReturnsUnauthorized() throws Exception { + mockMvc.perform(get(ACTIVE)) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(attendanceActiveService); + } + private static RequestPostProcessor loginAs(Long memberId) { return authentication(UsernamePasswordAuthenticationToken.authenticated(memberId, null, List.of())); } diff --git a/src/test/java/com/checkup/checkup/domain/attendance/service/AttendanceActiveServiceTest.java b/src/test/java/com/checkup/checkup/domain/attendance/service/AttendanceActiveServiceTest.java new file mode 100644 index 00000000..a6118566 --- /dev/null +++ b/src/test/java/com/checkup/checkup/domain/attendance/service/AttendanceActiveServiceTest.java @@ -0,0 +1,82 @@ +package com.checkup.checkup.domain.attendance.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.checkup.checkup.domain.attendance.dto.response.AttendanceActiveResponse; +import com.checkup.checkup.domain.attendance.entity.AttendancePurpose; +import com.checkup.checkup.domain.face.service.FaceRecognitionService; +import com.checkup.checkup.domain.member.entity.Student; +import com.checkup.checkup.domain.member.repository.StudentRepository; +import com.checkup.checkup.domain.qr.service.QrSessionService; +import com.checkup.checkup.global.exception.CustomException; +import com.checkup.checkup.global.exception.ErrorCode; + +/** + * 용도별 출석 접수 여부가 QR 세션 또는 학생 본인 층의 얼굴 인식 세션으로 정해지는지 검증한다(REQ-ATT-001, #260). + */ +class AttendanceActiveServiceTest { + + private static final Long MEMBER_ID = 1L; + + private final StudentRepository studentRepository = mock(StudentRepository.class); + private final QrSessionService qrSessionService = mock(QrSessionService.class); + private final FaceRecognitionService faceRecognitionService = mock(FaceRecognitionService.class); + private final AttendanceActiveService service = + new AttendanceActiveService(studentRepository, qrSessionService, faceRecognitionService); + + @Test + @DisplayName("QR 세션이 열린 용도는 층과 상관없이 출석을 받는 중이다") + void qrSessionMakesPurposeActive() { + givenStudentOnFloor(3); + given(qrSessionService.isAccepting(AttendancePurpose.DORMITORY)).willReturn(true); + + AttendanceActiveResponse response = service.getActive(MEMBER_ID); + + assertThat(response).isEqualTo(new AttendanceActiveResponse(true, false)); + } + + @Test + @DisplayName("본인 층 얼굴 인식 세션이 열린 용도는 출석을 받는 중이다") + void faceSessionOnOwnFloorMakesPurposeActive() { + givenStudentOnFloor(4); + given(faceRecognitionService.isAccepting(AttendancePurpose.STUDY_ROOM, 4)).willReturn(true); + + AttendanceActiveResponse response = service.getActive(MEMBER_ID); + + assertThat(response).isEqualTo(new AttendanceActiveResponse(false, true)); + } + + @Test + @DisplayName("열린 세션이 없으면 두 용도 모두 받지 않는다") + void noSessionMeansInactive() { + givenStudentOnFloor(5); + + assertThat(service.getActive(MEMBER_ID)).isEqualTo(new AttendanceActiveResponse(false, false)); + } + + @Test + @DisplayName("학생이 아니면 MISSING_STUDENT_INFO로 거부하고 세션을 보지 않는다") + void nonStudentIsRejected() { + given(studentRepository.findByMemberId(MEMBER_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getActive(MEMBER_ID)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.MISSING_STUDENT_INFO)); + verifyNoInteractions(qrSessionService, faceRecognitionService); + } + + private void givenStudentOnFloor(int floor) { + Student student = mock(Student.class); + given(student.getDormitoryFloor()).willReturn(floor); + given(studentRepository.findByMemberId(MEMBER_ID)).willReturn(Optional.of(student)); + } +} From 2e22cc78672bc2f131148a26f69a5fe6eca2117c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:50:51 +0900 Subject: [PATCH 7/7] =?UTF-8?q?chore:=20=ED=95=98=EB=84=A4=EC=8A=A4=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs-harness | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-harness b/docs-harness index ef805ce0..dd79ac3b 160000 --- a/docs-harness +++ b/docs-harness @@ -1 +1 @@ -Subproject commit ef805ce07abed711d33c33bb2f4b0f3533648fdf +Subproject commit dd79ac3bf536cbc0835b7a8e3dd13d3adfb29a39