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 3f004d50..1799d770 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,33 +30,6 @@ 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 3ba856f4..a7c3f932 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 roundId; + private UUID id; @Schema( description = "라운드 진행 날짜", @@ -35,7 +35,7 @@ public class AttendanceRoundResponse { format = "date" ) @JsonFormat(pattern = "yyyy-MM-dd") - private LocalDate roundDate; + private LocalDate date; @Schema( description = "라운드 출석 시작 시간", @@ -68,8 +68,8 @@ public static AttendanceRoundResponse fromEntity(AttendanceRound round) { String statusString = round.calculateCurrentStatus().getValue(); return AttendanceRoundResponse.builder() - .roundId(round.getRoundId()) - .roundDate(round.getRoundDate()) + .id(round.getRoundId()) + .date(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 d3a01520..ee753113 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.LocalDateTime; +import java.time.LocalTime; @Getter @Builder @@ -13,7 +13,7 @@ @Schema( title = "출석 세션 생성/수정 요청", description = "관리자가 출석 세션을 생성하거나 수정할 때 사용하는 요청 객체. " + - "세션의 기본 정보, 시간, 위치, 포인트 설정을 포함합니다." + "세션의 기본 정보, 기본 시간, 위치, 포인트 설정을 포함합니다." ) public class AttendanceSessionRequest { @@ -27,25 +27,24 @@ public class AttendanceSessionRequest { private String title; @Schema( - description = "세션 시작 시간 (ISO 8601 형식). 현재 시간 이후여야 합니다.", - example = "2024-11-15T14:00:00", + description = "세션의 기본 시작 시간 (HH:mm:ss 형식). 시간 단위만 지정합니다.", + example = "18:30:00", type = "string", - format = "date-time" + pattern = "HH:mm:ss" ) - @NotNull(message = "시작 시간은 필수입니다") - @Future(message = "시작 시간은 현재 시간 이후여야 합니다") - private LocalDateTime startsAt; + @NotNull(message = "기본 시작 시간은 필수입니다") + private LocalTime defaultStartTime; @Schema( - description = "출석 체크인이 가능한 시간 윈도우 (초 단위). " + - "범위: 300초(5분) ~ 14400초(4시간)", - example = "1800", - minimum = "300", - maximum = "14400" + description = "출석 인정 시간 (분 단위). " + + "범위: 5분 ~ 240분(4시간)", + example = "30", + minimum = "5", + maximum = "240" ) - @Min(value = 300, message = "최소 5분 이상이어야 합니다") - @Max(value = 14400, message = "최대 4시간 설정 가능합니다") - private Integer windowSeconds; + @Min(value = 5, message = "최소 5분 이상이어야 합니다") + @Max(value = 240, message = "최대 4시간 설정 가능합니다") + private Integer allowedMinutes; @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 e5459e01..40b258cc 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,12 +65,30 @@ public class Attendance extends BasePostgresEntity { /** * 지각 여부 판단 + * - 라운드 기반: attendanceRound의 startTime 기준 + * - 세션 기반: attendanceSession의 defaultStartTime 기준 (5분) */ public boolean isLate() { - if (checkedAt == null || attendanceSession.getStartsAt() == null) { + if (checkedAt == null) { return false; } - return checkedAt.isAfter(attendanceSession.getStartsAt()); + + 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); } /** 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 83452dd6..5c08e17d 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,6 +6,7 @@ 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; @@ -26,11 +27,11 @@ public class AttendanceSession extends BasePostgresEntity { @Column(nullable = false) private String title; // "세투연 9/17" - @Column(name = "starts_at", nullable = false) - private LocalDateTime startsAt; // 세션 시작 시간 + @Column(name = "default_start_time", nullable = false) + private LocalTime defaultStartTime; // 세션 기본 시작 시간 (예: 18:30:00) - @Column(name = "window_seconds") - private Integer windowSeconds; // 체크인 가능 시간(초) - 1800 = 30분 + @Column(name = "allowed_minutes", nullable = false) + private Integer allowedMinutes; // 출석 인정 시간(분) - 예: 30분 @Column(unique = true, length = 6) private String code; // 6자리 출석 코드 "942715" @@ -55,46 +56,23 @@ public class AttendanceSession extends BasePostgresEntity { private List attendances = new ArrayList<>(); /** - * 현재 세션 상태 계산 + * 세션 종료 시간 계산 (시간만) */ - 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 isCheckInAvailable() { - LocalDateTime now = LocalDateTime.now(); - return now.isAfter(startsAt) && now.isBefore(getEndsAt()); + public LocalTime getEndTime() { + return defaultStartTime.plusMinutes(allowedMinutes != null ? allowedMinutes : 30); } /** - * 세션 종료 시간 계산 + * 특정 라운드 날짜에서 세션이 진행 중인지 확인 */ - public LocalDateTime getEndsAt() { - return startsAt.plusSeconds(windowSeconds != null ? windowSeconds : 1800); + public boolean isCheckInAvailableForRound(java.time.LocalTime currentTime) { + return !currentTime.isBefore(defaultStartTime) && currentTime.isBefore(getEndTime()); } /** - * 남은 시간 계산 (초단위) + * 현재 세션 상태 계산 (라운드별) */ - public long getRemainingSeconds() { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime endsAt = getEndsAt(); - - if (now.isAfter(endsAt)) { - return 0; - } - - return java.time.Duration.between(now, endsAt).getSeconds(); + public SessionStatus calculateCurrentStatus() { + return SessionStatus.OPEN; } } 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 df5a32c2..d9d2067c 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,83 +31,6 @@ 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 ba31eaf7..4dd960f3 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,7 +35,9 @@ public class AttendanceSessionService { * - 기본 상태 UPCOMING 으로 설정 */ public AttendanceSessionResponse createSession(AttendanceSessionRequest request) { - log.info("출석 세션 생성 시작: 제목={}", request.getTitle()); + log.info("출석 세션 생성 시작: 제목={}, 기본시간={}, 출석인정시간={}분", + request.getTitle(), request.getDefaultStartTime(), request.getAllowedMinutes()); + String code = generateUniqueCode(); Location location = null; @@ -50,8 +52,8 @@ public AttendanceSessionResponse createSession(AttendanceSessionRequest request) AttendanceSession session = AttendanceSession.builder() .title(request.getTitle()) - .startsAt(request.getStartsAt()) - .windowSeconds(request.getWindowSeconds()) + .defaultStartTime(request.getDefaultStartTime()) + .allowedMinutes(request.getAllowedMinutes()) .code(code) .rewardPoints(request.getRewardPoints()) .location(location) @@ -80,11 +82,11 @@ public AttendanceSessionResponse getSessionById(UUID sessionId) { /** * 모든 세션 목록 조회 * - 관리자용, 공개/비공개 모두 포함 - * - 최신 순으로 정렬 + * - 생성 순으로 정렬 */ @Transactional(readOnly = true) public List getAllSessions() { - List sessions = attendanceSessionRepository.findAllByOrderByStartsAtDesc(); + List sessions = attendanceSessionRepository.findAll(); return sessions.stream() .map(this::convertToResponse) @@ -94,12 +96,11 @@ public List getAllSessions() { /** * 공개 세션 목록 조회 * - 학생들이 볼 수 있는 모든 세션만 조회 - * - 최신 순으로 정렬 + * - 생성 순으로 정렬 */ @Transactional(readOnly = true) public List getPublicSessions() { - List sessions = attendanceSessionRepository - .findAllByOrderByStartsAtDesc(); + List sessions = attendanceSessionRepository.findAll(); return sessions.stream() .map(this::convertToResponse) @@ -108,29 +109,22 @@ public List getPublicSessions() { /** * 활성 세션 목록 조회 - * - 현재 체크인 가능한 세션들만 필터링 - * - 시작 시간 ~ 종료 시간 범위 내 세션 + * - 현재 체크인 가능한 세션들만 필터링 (라운드 기반) + * - 세션의 상태가 OPEN인 세션들만 반환 */ @Transactional(readOnly = true) public List getActiveSessions() { - LocalDateTime now = LocalDateTime.now(); - List allSessions = attendanceSessionRepository.findAllByOrderByStartsAtDesc(); + List allSessions = attendanceSessionRepository.findAll(); return allSessions.stream() - .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()); + .filter(session -> session.getStatus() == SessionStatus.OPEN) + .map(this::convertToResponse) + .collect(Collectors.toList()); } /** * 세션 정보 수정 - * - 제목, 시간, 위치, 반경 등 수정 가능 + * - 제목, 기본시간, 출석인정시간, 위치, 반경 등 수정 가능 * - 코드는 변경되지 않음 (보안상 이유) */ public AttendanceSessionResponse updateSession(UUID sessionId, AttendanceSessionRequest request) { @@ -152,8 +146,8 @@ public AttendanceSessionResponse updateSession(UUID sessionId, AttendanceSession session = session.toBuilder() .title(request.getTitle()) - .startsAt(request.getStartsAt()) - .windowSeconds(request.getWindowSeconds()) + .defaultStartTime(request.getDefaultStartTime()) + .allowedMinutes(request.getAllowedMinutes()) .rewardPoints(request.getRewardPoints()) .location(location) .build(); @@ -184,7 +178,7 @@ public void deleteSession(UUID sessionId) { /** * 세션 수동 활성화 * - 세션 상태를 OPEN으로 변경 - * - 시간과 관계없이 체크인 활성화 + * - 라운드 기반이므로 세션 상태만 변경 */ public void activateSession(UUID sessionId) { log.info("출석 세션 활성화 시작: 세션ID={}", sessionId); @@ -194,7 +188,6 @@ public void activateSession(UUID sessionId) { session = session.toBuilder() .status(SessionStatus.OPEN) - .startsAt(LocalDateTime.now()) .build(); attendanceSessionRepository.save(session); @@ -279,9 +272,8 @@ private String generateRandomCode() { /** * AttendanceSession 엔티티를 Response DTO로 변환 - * - 기본 세션 정보: 제목, 시작 시간, 출석 인정 시간, 보상 포인트 + * - 기본 세션 정보: 제목, 기본 시작 시간, 출석 인정 시간, 보상 포인트 * - 위치 정보: location 객체 (lat, lng) - * - 공개 여부: isVisible boolean */ private AttendanceSessionResponse convertToResponse(AttendanceSession session) { // 위치 정보 변환 (location이 존재하면 LocationInfo 객체 생성, 없으면 null) @@ -293,18 +285,12 @@ 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(defaultStartTime) - .defaultAvailableMinutes(defaultAvailableMinutes) + .defaultStartTime(session.getDefaultStartTime()) + .defaultAvailableMinutes(session.getAllowedMinutes()) .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 58fc298a..378699c2 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() - .roundId(roundId) - .roundDate(roundDate) + .id(roundId) + .date(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("$.roundId").value(roundId.toString())) - .andExpect(jsonPath("$.roundDate").value(roundDate.toString())) + .andExpect(jsonPath("$.id").value(roundId.toString())) + .andExpect(jsonPath("$.date").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() - .roundId(roundId) - .roundDate(roundDate) + .id(roundId) + .date(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("$.roundId").value(roundId.toString())) + .andExpect(jsonPath("$.id").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() - .roundId(UUID.randomUUID()) - .roundDate(roundDate) + .id(UUID.randomUUID()) + .date(roundDate) .startTime(startTime) .availableMinutes(30) .status("active") .build(); AttendanceRoundResponse round2 = AttendanceRoundResponse.builder() - .roundId(UUID.randomUUID()) - .roundDate(roundDate.plusDays(7)) + .id(UUID.randomUUID()) + .date(roundDate.plusDays(7)) .startTime(startTime) .availableMinutes(30) .status("upcoming") @@ -192,8 +192,8 @@ void updateRound_success() throws Exception { .build(); AttendanceRoundResponse response = AttendanceRoundResponse.builder() - .roundId(roundId) - .roundDate(newDate) + .id(roundId) + .date(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("$.roundDate").value(newDate.toString())) + .andExpect(jsonPath("$.date").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() - .roundId(roundId) - .roundDate(targetDate) + .id(roundId) + .date(targetDate) .startTime(startTime) .availableMinutes(30) .status("active")