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
2 changes: 1 addition & 1 deletion docs-harness
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

/**
* 본인의 오늘 운영일 출석 상태를 기숙사 입소·자습실로 나눠 조회한다.
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,6 +24,12 @@ public interface FaceRecognitionSessionRepository extends JpaRepository<FaceReco

List<FaceRecognitionSession> findAllByIdIn(List<UUID> 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 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 쪽 삭제가 실패해 비활성 상태로 남은 세션도 여기서 다시 정리한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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와 함께 읽는다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}으로 운영한다.
Expand All @@ -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<Long> 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);

Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -104,13 +113,14 @@ public boolean saveIfPresent(QrSession session, Duration ttl) {
List<String> 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);
Expand Down Expand Up @@ -155,6 +165,17 @@ private static Map<String, String> 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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,21 +30,25 @@

/**
* 본인 출석 조회 API가 로그인한 회원 id로 서비스를 부르고, 용도별 출석 상태를 JSON 필드로 응답하며
* 미로그인·학생 아님 오류를 공통 형식으로 돌려주는지 검증한다.
* 미로그인·학생 아님 오류를 공통 형식으로 돌려주는지 검증한다. 지금 출석을 받는 중인지 조회도 함께 검증한다(#260).
*/
@WebMvcTest(AttendanceController.class)
@Import({SecurityConfig.class, GlobalExceptionHandler.class})
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;

@MockitoBean
private AttendanceService attendanceService;

@MockitoBean
private AttendanceActiveService attendanceActiveService;

@Test
@DisplayName("본인의 오늘 출석 상태를 운영일과 용도별 상태로 반환한다")
void returnsMyAttendanceByPurpose() throws Exception {
Expand Down Expand Up @@ -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()));
}
Expand Down
Loading
Loading