diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java index 1799d770..3f004d50 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java @@ -30,6 +30,33 @@ public class AttendanceController { private final AttendanceService attendanceService; + /** + * 학생 출석 체크인 + * - 출석 코드와 GPS 위치를 이요한 춣석 처리 + * - 위치 범위, 시간 위도우 검증 포함 + * - 중복 출석 방지 + */ + @Operation( + summary = "출석 체크인", + description = "학생이 출석 코드와 GPS 위치를 제시하여 출석 체크인을 진행합니다. " + + "세션의 지정된 위치 범위 내에 있어야 하며, 시간 윈도우 내에만 체크인이 가능합니다. " + + "시작 시간 5분 이내는 PRESENT, 이후는 LATE로 자동 판별됩니다." + ) + @PostMapping("/sessions/{sessionId}/check-in") + public ResponseEntity checkIn( + @PathVariable UUID sessionId, + @Valid @RequestBody AttendanceRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + log.info("출석 체크인 요청: 사용자={}, 코드={}", userDetails.getName(), request.getCode()); + + AttendanceResponse response = attendanceService.checkIn(sessionId, request, userDetails.getUserId()); + + log.info("출석 체크인 완료: 사용자={}, 상태={}", userDetails.getName(), response.getAttendanceStatus()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + /** * 세션별 출석 목록 조회(관리자용) * - 특정 세션의 모든 출석 기록 조회 diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundResponse.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundResponse.java index a7c3f932..3ba856f4 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundResponse.java @@ -26,7 +26,7 @@ public class AttendanceRoundResponse { description = "라운드의 고유 ID", example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ) - private UUID id; + private UUID roundId; @Schema( description = "라운드 진행 날짜", @@ -35,7 +35,7 @@ public class AttendanceRoundResponse { format = "date" ) @JsonFormat(pattern = "yyyy-MM-dd") - private LocalDate date; + private LocalDate roundDate; @Schema( description = "라운드 출석 시작 시간", @@ -68,8 +68,8 @@ public static AttendanceRoundResponse fromEntity(AttendanceRound round) { String statusString = round.calculateCurrentStatus().getValue(); return AttendanceRoundResponse.builder() - .id(round.getRoundId()) - .date(round.getRoundDate()) + .roundId(round.getRoundId()) + .roundDate(round.getRoundDate()) .startTime(round.getStartTime()) .availableMinutes(round.getAllowedMinutes()) .status(statusString) diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionRequest.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionRequest.java index ee753113..d3a01520 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionRequest.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.*; import lombok.*; -import java.time.LocalTime; +import java.time.LocalDateTime; @Getter @Builder @@ -13,7 +13,7 @@ @Schema( title = "출석 세션 생성/수정 요청", description = "관리자가 출석 세션을 생성하거나 수정할 때 사용하는 요청 객체. " + - "세션의 기본 정보, 기본 시간, 위치, 포인트 설정을 포함합니다." + "세션의 기본 정보, 시간, 위치, 포인트 설정을 포함합니다." ) public class AttendanceSessionRequest { @@ -27,24 +27,25 @@ public class AttendanceSessionRequest { private String title; @Schema( - description = "세션의 기본 시작 시간 (HH:mm:ss 형식). 시간 단위만 지정합니다.", - example = "18:30:00", + description = "세션 시작 시간 (ISO 8601 형식). 현재 시간 이후여야 합니다.", + example = "2024-11-15T14:00:00", type = "string", - pattern = "HH:mm:ss" + format = "date-time" ) - @NotNull(message = "기본 시작 시간은 필수입니다") - private LocalTime defaultStartTime; + @NotNull(message = "시작 시간은 필수입니다") + @Future(message = "시작 시간은 현재 시간 이후여야 합니다") + private LocalDateTime startsAt; @Schema( - description = "출석 인정 시간 (분 단위). " + - "범위: 5분 ~ 240분(4시간)", - example = "30", - minimum = "5", - maximum = "240" + description = "출석 체크인이 가능한 시간 윈도우 (초 단위). " + + "범위: 300초(5분) ~ 14400초(4시간)", + example = "1800", + minimum = "300", + maximum = "14400" ) - @Min(value = 5, message = "최소 5분 이상이어야 합니다") - @Max(value = 240, message = "최대 4시간 설정 가능합니다") - private Integer allowedMinutes; + @Min(value = 300, message = "최소 5분 이상이어야 합니다") + @Max(value = 14400, message = "최대 4시간 설정 가능합니다") + private Integer windowSeconds; @Schema( description = "출석 완료 시 지급할 포인트", diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/Attendance.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/Attendance.java index 40b258cc..e5459e01 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/Attendance.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/Attendance.java @@ -65,30 +65,12 @@ public class Attendance extends BasePostgresEntity { /** * 지각 여부 판단 - * - 라운드 기반: attendanceRound의 startTime 기준 - * - 세션 기반: attendanceSession의 defaultStartTime 기준 (5분) */ public boolean isLate() { - if (checkedAt == null) { + if (checkedAt == null || attendanceSession.getStartsAt() == null) { return false; } - - java.time.LocalTime checkTime = checkedAt.toLocalTime(); - java.time.LocalTime lateThreshold; - - // 라운드 기반 출석인 경우 - if (attendanceRound != null) { - lateThreshold = attendanceRound.getStartTime().plusMinutes(5); - } - // 세션 기반 출석인 경우 - else if (attendanceSession != null && attendanceSession.getDefaultStartTime() != null) { - lateThreshold = attendanceSession.getDefaultStartTime().plusMinutes(5); - } - else { - return false; - } - - return checkTime.isAfter(lateThreshold); + return checkedAt.isAfter(attendanceSession.getStartsAt()); } /** diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceSession.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceSession.java index 5c08e17d..83452dd6 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceSession.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceSession.java @@ -6,7 +6,6 @@ import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -27,11 +26,11 @@ public class AttendanceSession extends BasePostgresEntity { @Column(nullable = false) private String title; // "세투연 9/17" - @Column(name = "default_start_time", nullable = false) - private LocalTime defaultStartTime; // 세션 기본 시작 시간 (예: 18:30:00) + @Column(name = "starts_at", nullable = false) + private LocalDateTime startsAt; // 세션 시작 시간 - @Column(name = "allowed_minutes", nullable = false) - private Integer allowedMinutes; // 출석 인정 시간(분) - 예: 30분 + @Column(name = "window_seconds") + private Integer windowSeconds; // 체크인 가능 시간(초) - 1800 = 30분 @Column(unique = true, length = 6) private String code; // 6자리 출석 코드 "942715" @@ -56,23 +55,46 @@ public class AttendanceSession extends BasePostgresEntity { private List attendances = new ArrayList<>(); /** - * 세션 종료 시간 계산 (시간만) + * 현재 세션 상태 계산 */ - public LocalTime getEndTime() { - return defaultStartTime.plusMinutes(allowedMinutes != null ? allowedMinutes : 30); + public SessionStatus calculateCurrentStatus() { + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(startsAt)) { + return SessionStatus.UPCOMING; + } else if (now.isAfter(getEndsAt())) { + return SessionStatus.CLOSED; + } else { + return SessionStatus.OPEN; + } } /** - * 특정 라운드 날짜에서 세션이 진행 중인지 확인 + * 세션 종료 시간 계산 */ - public boolean isCheckInAvailableForRound(java.time.LocalTime currentTime) { - return !currentTime.isBefore(defaultStartTime) && currentTime.isBefore(getEndTime()); + public boolean isCheckInAvailable() { + LocalDateTime now = LocalDateTime.now(); + return now.isAfter(startsAt) && now.isBefore(getEndsAt()); } /** - * 현재 세션 상태 계산 (라운드별) + * 세션 종료 시간 계산 */ - public SessionStatus calculateCurrentStatus() { - return SessionStatus.OPEN; + public LocalDateTime getEndsAt() { + return startsAt.plusSeconds(windowSeconds != null ? windowSeconds : 1800); + } + + /** + * 남은 시간 계산 (초단위) + */ + public long getRemainingSeconds() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime endsAt = getEndsAt(); + + if (now.isAfter(endsAt)) { + return 0; + } + + return java.time.Duration.between(now, endsAt).getSeconds(); } } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java index d9d2067c..df5a32c2 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java @@ -31,6 +31,83 @@ public class AttendanceService { private final AttendanceRoundRepository attendanceRoundRepository; private final UserRepository userRepository; + + /** + * 출석 체크인 처리 + * - 코드 유효성 및 중복 출석 방지 + * - GPS 위치 및 반경 검증 + * - 시간 윈도우 검증 및 지각 판별 + */ + public AttendanceResponse checkIn(UUID sessionId, AttendanceRequest request, UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다: " + userId)); + log.info("출석 체크인 시작: 사용자={}, 세션ID={}, 코드={}", user.getName(), sessionId, request.getCode()); + + + // 세션ID로 세션 조회 + AttendanceSession session = attendanceSessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); + + // 세션의 코드와 요청된 코드가 일치하는지 검증 + if (!session.getCode().equals(request.getCode())) { + throw new IllegalArgumentException("세션 코드가 일치하지 않습니다"); + } + + if (attendanceRepository.existsByAttendanceSessionAndUser(session, user)) { + throw new IllegalStateException("이미 출석 체크인한 세션입니다"); + } + + // 위치 정보가 있는 세션에 대해서만 사용자 위치 생성 및 검증 + Location userLocation = null; + if (session.getLocation() != null) { + if (request.getLatitude() == null || request.getLongitude() == null) { + throw new IllegalArgumentException("위치 기반 출석에는 위도와 경도가 필요합니다"); + } + + userLocation = Location.builder() + .lat(request.getLatitude()) + .lng(request.getLongitude()) + .build(); + + if (!session.getLocation().isWithRange(userLocation)) { + throw new IllegalArgumentException("출석 허용 범위를 벗어났습니다"); + } + } + + + LocalDateTime now = LocalDateTime.now(); + if (now.isBefore(session.getStartsAt())) { + throw new IllegalStateException("아직 출석 시간이 아닙니다"); + } + + LocalDateTime endTime = session.getEndsAt(); + if (now.isAfter(endTime)) { + throw new IllegalStateException("출석 시간이 종료되었습니다"); + } + + // 시작 후 5분 이내는 정상 출석, 이후는 지각 + LocalDateTime lateThreshold = session.getStartsAt().plusMinutes(5); + AttendanceStatus status = now.isAfter(lateThreshold) ? + AttendanceStatus.LATE : AttendanceStatus.PRESENT; + + Attendance attendance = Attendance.builder() + .user(user) + .attendanceSession(session) + .attendanceStatus(status) + .checkedAt(now) + .awardedPoints(session.getRewardPoints()) + .note(request.getNote()) + .checkInLocation(userLocation) + .deviceInfo(request.getDeviceInfo()) + .build(); + + attendance = attendanceRepository.save(attendance); + + log.info("출석 체크인 완료: 사용자={}, 상태={}", user.getName(), status); + + return convertToResponse(attendance); + } + /** * 라운드 기반 출석 체크인 처리 * - 특정 라운드의 시간 및 위치 검증 diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java index 4dd960f3..ba31eaf7 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java @@ -35,9 +35,7 @@ public class AttendanceSessionService { * - 기본 상태 UPCOMING 으로 설정 */ public AttendanceSessionResponse createSession(AttendanceSessionRequest request) { - log.info("출석 세션 생성 시작: 제목={}, 기본시간={}, 출석인정시간={}분", - request.getTitle(), request.getDefaultStartTime(), request.getAllowedMinutes()); - + log.info("출석 세션 생성 시작: 제목={}", request.getTitle()); String code = generateUniqueCode(); Location location = null; @@ -52,8 +50,8 @@ public AttendanceSessionResponse createSession(AttendanceSessionRequest request) AttendanceSession session = AttendanceSession.builder() .title(request.getTitle()) - .defaultStartTime(request.getDefaultStartTime()) - .allowedMinutes(request.getAllowedMinutes()) + .startsAt(request.getStartsAt()) + .windowSeconds(request.getWindowSeconds()) .code(code) .rewardPoints(request.getRewardPoints()) .location(location) @@ -82,11 +80,11 @@ public AttendanceSessionResponse getSessionById(UUID sessionId) { /** * 모든 세션 목록 조회 * - 관리자용, 공개/비공개 모두 포함 - * - 생성 순으로 정렬 + * - 최신 순으로 정렬 */ @Transactional(readOnly = true) public List getAllSessions() { - List sessions = attendanceSessionRepository.findAll(); + List sessions = attendanceSessionRepository.findAllByOrderByStartsAtDesc(); return sessions.stream() .map(this::convertToResponse) @@ -96,11 +94,12 @@ public List getAllSessions() { /** * 공개 세션 목록 조회 * - 학생들이 볼 수 있는 모든 세션만 조회 - * - 생성 순으로 정렬 + * - 최신 순으로 정렬 */ @Transactional(readOnly = true) public List getPublicSessions() { - List sessions = attendanceSessionRepository.findAll(); + List sessions = attendanceSessionRepository + .findAllByOrderByStartsAtDesc(); return sessions.stream() .map(this::convertToResponse) @@ -109,22 +108,29 @@ public List getPublicSessions() { /** * 활성 세션 목록 조회 - * - 현재 체크인 가능한 세션들만 필터링 (라운드 기반) - * - 세션의 상태가 OPEN인 세션들만 반환 + * - 현재 체크인 가능한 세션들만 필터링 + * - 시작 시간 ~ 종료 시간 범위 내 세션 */ @Transactional(readOnly = true) public List getActiveSessions() { - List allSessions = attendanceSessionRepository.findAll(); + LocalDateTime now = LocalDateTime.now(); + List allSessions = attendanceSessionRepository.findAllByOrderByStartsAtDesc(); return allSessions.stream() - .filter(session -> session.getStatus() == SessionStatus.OPEN) - .map(this::convertToResponse) - .collect(Collectors.toList()); + .filter(session -> { + if (session.getStatus() != SessionStatus.OPEN) { + return false; + } + LocalDateTime endTime = session.getStartsAt().plusSeconds(session.getWindowSeconds()); + return !now.isBefore(session.getStartsAt()) && now.isBefore(endTime); + }) + .map(this::convertToResponse) + .collect(Collectors.toList()); } /** * 세션 정보 수정 - * - 제목, 기본시간, 출석인정시간, 위치, 반경 등 수정 가능 + * - 제목, 시간, 위치, 반경 등 수정 가능 * - 코드는 변경되지 않음 (보안상 이유) */ public AttendanceSessionResponse updateSession(UUID sessionId, AttendanceSessionRequest request) { @@ -146,8 +152,8 @@ public AttendanceSessionResponse updateSession(UUID sessionId, AttendanceSession session = session.toBuilder() .title(request.getTitle()) - .defaultStartTime(request.getDefaultStartTime()) - .allowedMinutes(request.getAllowedMinutes()) + .startsAt(request.getStartsAt()) + .windowSeconds(request.getWindowSeconds()) .rewardPoints(request.getRewardPoints()) .location(location) .build(); @@ -178,7 +184,7 @@ public void deleteSession(UUID sessionId) { /** * 세션 수동 활성화 * - 세션 상태를 OPEN으로 변경 - * - 라운드 기반이므로 세션 상태만 변경 + * - 시간과 관계없이 체크인 활성화 */ public void activateSession(UUID sessionId) { log.info("출석 세션 활성화 시작: 세션ID={}", sessionId); @@ -188,6 +194,7 @@ public void activateSession(UUID sessionId) { session = session.toBuilder() .status(SessionStatus.OPEN) + .startsAt(LocalDateTime.now()) .build(); attendanceSessionRepository.save(session); @@ -272,8 +279,9 @@ private String generateRandomCode() { /** * AttendanceSession 엔티티를 Response DTO로 변환 - * - 기본 세션 정보: 제목, 기본 시작 시간, 출석 인정 시간, 보상 포인트 + * - 기본 세션 정보: 제목, 시작 시간, 출석 인정 시간, 보상 포인트 * - 위치 정보: location 객체 (lat, lng) + * - 공개 여부: isVisible boolean */ private AttendanceSessionResponse convertToResponse(AttendanceSession session) { // 위치 정보 변환 (location이 존재하면 LocationInfo 객체 생성, 없으면 null) @@ -285,12 +293,18 @@ private AttendanceSessionResponse convertToResponse(AttendanceSession session) { .build(); } + // defaultAvailableMinutes: windowSeconds를 분 단위로 변환 + Integer defaultAvailableMinutes = (int) (session.getWindowSeconds() / 60); + + // defaultStartTime: startsAt에서 시간 부분만 추출 + LocalTime defaultStartTime = session.getStartsAt().toLocalTime(); + return AttendanceSessionResponse.builder() .attendanceSessionId(session.getAttendanceSessionId()) .title(session.getTitle()) .location(location) - .defaultStartTime(session.getDefaultStartTime()) - .defaultAvailableMinutes(session.getAllowedMinutes()) + .defaultStartTime(defaultStartTime) + .defaultAvailableMinutes(defaultAvailableMinutes) .rewardPoints(session.getRewardPoints()) .build(); diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceRoundControllerTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceRoundControllerTest.java index 378699c2..58fc298a 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceRoundControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceRoundControllerTest.java @@ -76,8 +76,8 @@ void createRound_success() throws Exception { .build(); AttendanceRoundResponse response = AttendanceRoundResponse.builder() - .id(roundId) - .date(roundDate) + .roundId(roundId) + .roundDate(roundDate) .startTime(startTime) .availableMinutes(30) .status("upcoming") @@ -91,8 +91,8 @@ void createRound_success() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").value(roundId.toString())) - .andExpect(jsonPath("$.date").value(roundDate.toString())) + .andExpect(jsonPath("$.roundId").value(roundId.toString())) + .andExpect(jsonPath("$.roundDate").value(roundDate.toString())) .andExpect(jsonPath("$.startTime").value("14:00:00")) .andExpect(jsonPath("$.availableMinutes").value(30)) .andExpect(jsonPath("$.status").value("upcoming")); @@ -125,8 +125,8 @@ void getRound_success() throws Exception { LocalTime startTime = LocalTime.of(14, 0); AttendanceRoundResponse response = AttendanceRoundResponse.builder() - .id(roundId) - .date(roundDate) + .roundId(roundId) + .roundDate(roundDate) .startTime(startTime) .availableMinutes(30) .status("active") @@ -138,7 +138,7 @@ void getRound_success() throws Exception { mockMvc.perform(get("/api/attendance/rounds/{roundId}", roundId) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(roundId.toString())) + .andExpect(jsonPath("$.roundId").value(roundId.toString())) .andExpect(jsonPath("$.status").value("active")); } @@ -151,16 +151,16 @@ void getRoundsBySession_success() throws Exception { LocalTime startTime = LocalTime.of(14, 0); AttendanceRoundResponse round1 = AttendanceRoundResponse.builder() - .id(UUID.randomUUID()) - .date(roundDate) + .roundId(UUID.randomUUID()) + .roundDate(roundDate) .startTime(startTime) .availableMinutes(30) .status("active") .build(); AttendanceRoundResponse round2 = AttendanceRoundResponse.builder() - .id(UUID.randomUUID()) - .date(roundDate.plusDays(7)) + .roundId(UUID.randomUUID()) + .roundDate(roundDate.plusDays(7)) .startTime(startTime) .availableMinutes(30) .status("upcoming") @@ -192,8 +192,8 @@ void updateRound_success() throws Exception { .build(); AttendanceRoundResponse response = AttendanceRoundResponse.builder() - .id(roundId) - .date(newDate) + .roundId(roundId) + .roundDate(newDate) .startTime(newStartTime) .availableMinutes(45) .status("upcoming") @@ -207,7 +207,7 @@ void updateRound_success() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.date").value(newDate.toString())) + .andExpect(jsonPath("$.roundDate").value(newDate.toString())) .andExpect(jsonPath("$.startTime").value("15:00:00")) .andExpect(jsonPath("$.availableMinutes").value(45)); } @@ -319,8 +319,8 @@ void getRoundByDate_success() throws Exception { LocalTime startTime = LocalTime.of(14, 0); AttendanceRoundResponse response = AttendanceRoundResponse.builder() - .id(roundId) - .date(targetDate) + .roundId(roundId) + .roundDate(targetDate) .startTime(startTime) .availableMinutes(30) .status("active")