diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java new file mode 100644 index 00000000..58efd94c --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java @@ -0,0 +1,254 @@ +package org.sejongisc.backend.attendance.controller; + +import io.jsonwebtoken.JwtException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.attendance.dto.AttendanceCheckInRequest; +import org.sejongisc.backend.attendance.dto.AttendanceCheckInResponse; +import org.sejongisc.backend.attendance.dto.AttendanceRoundRequest; +import org.sejongisc.backend.attendance.dto.AttendanceRoundResponse; +import org.sejongisc.backend.attendance.service.AttendanceRoundService; +import org.sejongisc.backend.attendance.service.AttendanceService; +import org.sejongisc.backend.common.auth.jwt.JwtProvider; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/attendance") +@RequiredArgsConstructor +@Slf4j +@Tag( + name = "출석 라운드(Attendance Round) API", + description = "출석 라운드(주차별 회차) 생성, 조회, 수정, 삭제 및 출석 체크인 관련 API" +) +public class AttendanceRoundController { + + private final AttendanceRoundService attendanceRoundService; + private final AttendanceService attendanceService; + private final JwtProvider jwtProvider; + + /** + * 라운드 생성 + * POST /api/attendance/sessions/{sessionId}/rounds + */ + @Operation( + summary = "라운드 생성", + description = "세션에 새로운 출석 라운드를 생성합니다. " + + "라운드 날짜, 시작 시간, 출석 가능 시간을 설정할 수 있습니다." + ) + @PostMapping("/sessions/{sessionId}/rounds") + @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") + public ResponseEntity createRound( + @PathVariable UUID sessionId, + @Valid @RequestBody AttendanceRoundRequest request) { + log.info("📋 라운드 생성 요청 도착:"); + log.info(" - sessionId: {}", sessionId); + log.info(" - roundDate: {} (타입: {})", request.getRoundDate(), request.getRoundDate() != null ? request.getRoundDate().getClass().getSimpleName() : "null"); + log.info(" - startTime: {} (타입: {})", request.getStartTime(), request.getStartTime() != null ? request.getStartTime().getClass().getSimpleName() : "null"); + log.info(" - allowedMinutes: {}", request.getAllowedMinutes()); + + if (request.getStartTime() != null) { + log.info(" - startTime 상세: 시간={}, 분={}, 초={}", + request.getStartTime().getHour(), + request.getStartTime().getMinute(), + request.getStartTime().getSecond()); + } + + AttendanceRoundResponse response = attendanceRoundService.createRound(sessionId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 라운드 조회 (개별) + * GET /api/attendance/rounds/{roundId} + */ + @Operation( + summary = "라운드 조회", + description = "지정된 라운드 ID로 라운드 정보를 조회합니다. " + + "라운드의 상태, 날짜, 시간, 참석 현황 등의 정보를 반환합니다." + ) + @GetMapping("/rounds/{roundId}") + public ResponseEntity getRound(@PathVariable UUID roundId) { + log.info("라운드 조회: roundId={}", roundId); + AttendanceRoundResponse response = attendanceRoundService.getRound(roundId); + return ResponseEntity.ok(response); + } + + /** + * 세션 내 라운드 목록 조회 + * GET /api/attendance/sessions/{sessionId}/rounds + */ + @Operation( + summary = "세션의 라운드 목록 조회", + description = "지정된 세션에 속한 모든 라운드 목록을 조회합니다. " + + "각 라운드의 상태, 시간, 참석 현황을 포함합니다." + ) + @GetMapping("/sessions/{sessionId}/rounds") + public ResponseEntity> getRoundsBySession( + @PathVariable UUID sessionId) { + log.info("세션 내 라운드 목록 조회: sessionId={}", sessionId); + List response = attendanceRoundService.getRoundsBySession(sessionId); + return ResponseEntity.ok(response); + } + + /** + * 라운드 정보 수정 + * PUT /api/attendance/rounds/{roundId} + */ + @Operation( + summary = "라운드 정보 수정", + description = "지정된 라운드의 정보를 수정합니다. " + + "라운드 날짜, 시작 시간, 출석 가능 시간 등을 변경할 수 있습니다." + ) + @PutMapping("/rounds/{roundId}") + @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") + public ResponseEntity updateRound( + @PathVariable UUID roundId, + @Valid @RequestBody AttendanceRoundRequest request) { + log.info("라운드 수정: roundId={}", roundId); + AttendanceRoundResponse response = attendanceRoundService.updateRound(roundId, request); + return ResponseEntity.ok(response); + } + + /** + * 라운드 삭제 + * DELETE /api/attendance/rounds/{roundId} + */ + @Operation( + summary = "라운드 삭제", + description = "지정된 라운드를 삭제합니다. " + + "라운드와 관련된 모든 출석 기록도 함께 삭제됩니다." + ) + @DeleteMapping("/rounds/{roundId}") + @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") + public ResponseEntity deleteRound(@PathVariable UUID roundId) { + log.info("라운드 삭제: roundId={}", roundId); + attendanceRoundService.deleteRound(roundId); + return ResponseEntity.noContent().build(); + } + + /** + * 라운드 기반 출석 체크인 + * POST /api/attendance/rounds/check-in + */ + @Operation( + summary = "라운드 출석 체크인", + description = "라운드에 출석 체크인을 기록합니다. " + + "라운드 ID와 위치 정보(위도, 경도)를 전송하면 출석 여부를 판단합니다. " + + "인증되지 않은 사용자는 이름을 입력하여 익명으로 출석할 수 있습니다." + ) + @PostMapping("/rounds/check-in") + public ResponseEntity checkInByRound( + @Valid @RequestBody AttendanceCheckInRequest request, + Authentication authentication, + HttpServletRequest httpRequest) { + UUID userId; + + // 인증된 경우 사용자 ID 추출, 미인증인 경우 임시 ID 생성 + if (authentication != null && authentication.isAuthenticated() + && !(authentication instanceof AnonymousAuthenticationToken)) { + userId = extractUserId(authentication, httpRequest); + log.info("라운드 출석 체크인 요청 (인증됨): roundId={}, userId={}", request.getRoundId(), userId); + } else { + // 미인증 사용자: 임시 ID 사용 + userId = UUID.randomUUID(); + log.info("라운드 출석 체크인 요청 (미인증): roundId={}, 임시userId={}", request.getRoundId(), userId); + } + + AttendanceCheckInResponse response = attendanceService.checkInByRound(request, userId); + return ResponseEntity.ok(response); + } + + /** + * Authentication에서 사용자 ID를 추출합니다. + * JWT 토큰을 파싱하여 UUID를 반환하며, 파싱에 실패하면 예외를 던집니다. + * + * @param authentication 스프링 시큐리티 Authentication 객체 + * @param httpRequest HTTP 요청 객체 + * @return 추출된 사용자 UUID + * @throws IllegalStateException JWT 파싱 또는 UUID 변환에 실패한 경우 + */ + private UUID extractUserId(Authentication authentication, HttpServletRequest httpRequest) { + try { + // JWT 토큰에서 Authorization 헤더 추출 + String authHeader = httpRequest.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + throw new IllegalStateException("Authorization 헤더가 없거나 형식이 올바르지 않습니다."); + } + + String token = authHeader.substring(7); + // JwtProvider를 통해 uid 클레임에서 userId 추출 + String userIdStr = jwtProvider.getUserIdFromToken(token); + if (userIdStr == null || userIdStr.isBlank()) { + throw new IllegalStateException("토큰에서 사용자 ID를 찾을 수 없습니다."); + } + + return UUID.fromString(userIdStr); + } catch (JwtException e) { + log.error("JWT 파싱 실패: {}", e.getMessage(), e); + throw new IllegalStateException("인증 정보가 유효하지 않습니다. JWT 파싱에 실패했습니다.", e); + } catch (IllegalArgumentException e) { + log.error("UUID 변환 실패: {}", e.getMessage(), e); + throw new IllegalStateException("사용자 ID가 유효한 UUID 형식이 아닙니다.", e); + } catch (Exception e) { + log.error("사용자 ID 추출 중 오류 발생: {}", e.getMessage(), e); + throw new IllegalStateException("사용자 ID를 확인할 수 없습니다.", e); + } + } + + /** + * 특정 날짜의 라운드 조회 + * GET /api/attendance/sessions/{sessionId}/rounds/by-date + */ + @Operation( + summary = "특정 날짜의 라운드 조회", + description = "지정된 세션과 날짜로 라운드를 조회합니다. " + + "특정 날짜에만 진행되는 라운드를 찾을 때 사용합니다." + ) + @GetMapping("/sessions/{sessionId}/rounds/by-date") + public ResponseEntity getRoundByDate( + @PathVariable UUID sessionId, + @RequestParam LocalDate date) { + log.info("날짜별 라운드 조회: sessionId={}, date={}", sessionId, date); + AttendanceRoundResponse response = attendanceRoundService.getRoundByDate(sessionId, date); + return ResponseEntity.ok(response); + } + + /** + * 라운드별 출석 명단 조회 + * GET /api/attendance/rounds/{roundId}/attendances + */ + @Operation( + summary = "라운드별 출석 명단 조회", + description = "지정된 라운드의 모든 출석 기록을 조회합니다. " + + "참석자, 지각자, 결석자 등의 출석 상태별 명단을 반환합니다." + ) + @GetMapping("/rounds/{roundId}/attendances") + @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") + public ResponseEntity getAttendancesByRound( + @PathVariable UUID roundId) { + log.info("라운드별 출석 명단 조회: roundId={}", roundId); + // 라운드 조회 및 해당 라운드의 모든 출석 기록 반환 + try { + var round = attendanceService.getAttendancesByRound(roundId); + return ResponseEntity.ok(round); + } catch (Exception e) { + log.error("라운드별 출석 명단 조회 실패: {}", e.getMessage()); + return ResponseEntity.status(400).body(new java.util.HashMap() {{ + put("error", "라운드를 찾을 수 없습니다"); + }}); + } + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java index c2d3c136..61bb0bbf 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java @@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.attendance.dto.AttendanceSessionRequest; import org.sejongisc.backend.attendance.dto.AttendanceSessionResponse; +import org.sejongisc.backend.attendance.dto.SessionLocationUpdateRequest; import org.sejongisc.backend.attendance.entity.SessionStatus; import org.sejongisc.backend.attendance.service.AttendanceSessionService; import org.springframework.http.HttpStatus; @@ -72,25 +73,6 @@ public ResponseEntity getSession(@PathVariable UUID s return ResponseEntity.ok(response); } - /** - * 출석 코드로 세션 조회 - * - 학생이 출석 코드 입력 시 사용 - * - 체크인 가능 여부 확인 - */ - @Operation( - summary = "출석 코드로 세션 조회", - description = "6자리 출석 코드를 입력하여 해당 세션을 조회합니다. (학생용) " + - "체크인 가능 여부, 남은 시간 등의 정보를 확인할 수 있습니다." - ) - @GetMapping("/code/{code}") - public ResponseEntity getSessionByCode(@PathVariable String code) { - log.info("출석 코드로 세션 조회: 코드={}", code); - - AttendanceSessionResponse response = attendanceSessionService.getSessionByCode(code); - - return ResponseEntity.ok(response); - } - /** * 모든 세션 목록 조회 * - 최신 순으로 정렬 @@ -148,43 +130,6 @@ public ResponseEntity> getActiveSessions() { return ResponseEntity.ok(sessions); } - /** - * 태그별 세션 목록 조회 - * - "금융IT", "동아리 전체" 등 태그로 필터링 - */ - @Operation( - summary = "태그별 세션 목록 조회", - description = "특정 태그를 가진 세션들을 조회합니다. " + - "예: '금융IT', '동아리 전체', '스터디' 등으로 세션을 분류할 수 있습니다." - ) - @GetMapping("/tag/{tag}") - public ResponseEntity> getSessionsByTag(@PathVariable String tag) { - log.info("태그별 출석 세션 조회: 태그={}", tag); - - List sessions = attendanceSessionService.getSessionsByTag(tag); - - return ResponseEntity.ok(sessions); - } - - /** - * 상태별 세션 목록 조회 (관리자용) - * - UPCOMING/OPEN/CLOSED 상태별 필터링 - */ - @Operation( - summary = "상태별 세션 목록 조회", - description = "특정 상태의 세션들을 조회합니다. (관리자 전용) " + - "UPCOMING(예정), OPEN(진행중), CLOSED(종료) 상태별로 필터링할 수 있습니다." - ) - @GetMapping("/status/{status}") - @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") - public ResponseEntity> getSessionsByStatus(@PathVariable SessionStatus status) { - log.info("상태별 출석 세션 조회: 상태={}", status); - - List sessions = attendanceSessionService.getSessionsByStatus(status); - - return ResponseEntity.ok(sessions); - } - /** * 세션 정보 수정 (관리자용) * - 제목, 시간, 위치, 반경 등 수정 가능 @@ -255,6 +200,31 @@ public ResponseEntity closeSession(@PathVariable UUID sessionId) { return ResponseEntity.ok().build(); } + /** + * 세션 위치 재설정 (관리자용) + * - 기존 위치 정보를 새로운 위치로 업데이트 + * - 반경은 기존 값 유지 + */ + @Operation( + summary = "세션 위치 재설정", + description = "세션의 위치 정보를 재설정합니다. (관리자 전용) " + + "새로운 위도와 경도로 출석 기반 위치 검증 범위를 변경할 수 있습니다." + ) + @PutMapping("/{sessionId}/location") + @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") + public ResponseEntity updateSessionLocation( + @PathVariable UUID sessionId, + @Valid @RequestBody SessionLocationUpdateRequest request) { + log.info("세션 위치 재설정: 세션ID={}, 위도={}, 경도={}", + sessionId, request.getLatitude(), request.getLongitude()); + + AttendanceSessionResponse response = attendanceSessionService.updateSessionLocation(sessionId, request); + + log.info("세션 위치 재설정 완료: 세션ID={}", sessionId); + + return ResponseEntity.ok(response); + } + /** * 세션 삭제 (관리자용) * - 세션 완전 삭제 (출석 기록도 함께 삭제) diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInRequest.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInRequest.java new file mode 100644 index 00000000..472a04fa --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInRequest.java @@ -0,0 +1,63 @@ +package org.sejongisc.backend.attendance.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * 출석 체크인 요청 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema( + title = "출석 체크인 요청", + description = "라운드에 출석 체크인을 기록할 때 사용하는 요청 객체. " + + "라운드 ID, 현재 위치(GPS), 사용자 이름을 포함합니다." +) +public class AttendanceCheckInRequest { + + @NotNull(message = "라운드 ID는 필수입니다") + @Schema( + description = "체크인할 라운드의 고유 ID", + example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ) + private UUID roundId; + + @NotNull(message = "위도는 필수입니다") + @DecimalMin(value = "-90.0", message = "위도는 -90도 이상이어야 합니다") + @DecimalMax(value = "90.0", message = "위도는 90도 이하여야 합니다") + @Schema( + description = "현재 사용자의 위치 위도 (WGS84 좌표계)", + example = "37.4979", + minimum = "-90.0", + maximum = "90.0" + ) + private Double latitude; + + @NotNull(message = "경도는 필수입니다") + @DecimalMin(value = "-180.0", message = "경도는 -180도 이상이어야 합니다") + @DecimalMax(value = "180.0", message = "경도는 180도 이하여야 합니다") + @Schema( + description = "현재 사용자의 위치 경도 (WGS84 좌표계)", + example = "127.0276", + minimum = "-180.0", + maximum = "180.0" + ) + private Double longitude; + + @Schema( + description = "익명 사용자의 이름 (선택사항). 입력하지 않으면 '익명사용자-{UUID}'로 자동 생성됨.", + example = "김철수", + nullable = true + ) + private String userName; +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInResponse.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInResponse.java new file mode 100644 index 00000000..72026796 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInResponse.java @@ -0,0 +1,35 @@ +package org.sejongisc.backend.attendance.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 출석 체크인 응답 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AttendanceCheckInResponse { + + private UUID roundId; // 라운드 ID + + private Boolean success; // 출석 성공 여부 + + private String status; // 출석 상태 (PRESENT, LATE, ABSENT) + + private String failureReason; // 실패 사유 (시간초과, 위치 불일치, 등) + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime checkedAt; // 출석 시간 + + private Integer awardedPoints; // 지급된 포인트 + + private Long remainingSeconds; // 남은 체크인 시간 (초단위) +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceResponse.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceResponse.java index c76056dd..23c1c26d 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceResponse.java @@ -42,6 +42,12 @@ public class AttendanceResponse { ) private UUID attendanceSessionId; + @Schema( + description = "해당 출석 라운드의 ID", + example = "b5c3d4e5-f6a7-8901-bcde-f12345678901" + ) + private UUID attendanceRoundId; + @Schema( description = "출석 상태. PRESENT(출석), LATE(지각), ABSENT(결석), EXCUSED(사유결석)", example = "PRESENT", diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundRequest.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundRequest.java new file mode 100644 index 00000000..2d77c9b5 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundRequest.java @@ -0,0 +1,54 @@ +package org.sejongisc.backend.attendance.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema( + title = "출석 라운드 생성/수정 요청", + description = "출석 라운드를 생성하거나 수정할 때 사용하는 요청 객체. " + + "라운드의 날짜, 시작 시간, 출석 가능한 시간을 설정합니다." +) +public class AttendanceRoundRequest { + + @Schema( + description = "라운드 진행 날짜 (YYYY-MM-DD 형식)", + example = "2025-11-06", + type = "string", + format = "date" + ) + private LocalDate roundDate; + + @NotNull(message = "시작 시간은 필수입니다") + @Schema( + description = "라운드 출석 시작 시간 (HH:mm:ss 형식). 이 시간부터 출석 체크인이 가능합니다.", + example = "10:00:00", + type = "string", + format = "time" + ) + private LocalTime startTime; + + @NotNull(message = "출석 가능 시간은 필수입니다") + @Min(value = 1, message = "출석 가능 시간은 최소 1분 이상이어야 합니다") + @Max(value = 120, message = "출석 가능 시간은 최대 120분 이하여야 합니다") + @Schema( + description = "출석 가능한 시간 (분단위). 시작 시간으로부터 이 시간 동안 출석을 기록할 수 있습니다. " + + "범위: 1분 ~ 120분", + example = "30", + minimum = "1", + maximum = "120" + ) + private Integer allowedMinutes; +} 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 new file mode 100644 index 00000000..e600bd5b --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundResponse.java @@ -0,0 +1,148 @@ +package org.sejongisc.backend.attendance.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sejongisc.backend.attendance.entity.AttendanceRound; +import org.sejongisc.backend.attendance.entity.RoundStatus; +import org.sejongisc.backend.attendance.entity.AttendanceStatus; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema( + title = "출석 라운드 응답", + description = "출석 라운드의 상세 정보. 라운드 상태, 시간, 출석 현황 통계를 포함합니다." +) +public class AttendanceRoundResponse { + + @Schema( + description = "라운드의 고유 ID", + example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ) + private UUID roundId; + + @Schema( + description = "라운드 진행 날짜", + example = "2025-11-06", + type = "string", + format = "date" + ) + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate roundDate; + + @Schema( + description = "라운드 출석 시작 시간", + example = "10:00", + type = "string", + format = "time" + ) + @JsonFormat(pattern = "HH:mm") + private LocalTime startTime; + + @Schema( + description = "라운드 출석 종료 시간 (startTime + allowedMinutes)", + example = "10:30", + type = "string", + format = "time" + ) + @JsonFormat(pattern = "HH:mm") + private LocalTime endTime; + + @Schema( + description = "출석 가능한 시간 (분단위)", + example = "30" + ) + private Integer allowedMinutes; + + @Schema( + description = "라운드의 현재 상태. UPCOMING(시작 전), ACTIVE(진행 중), CLOSED(종료됨)", + example = "ACTIVE", + implementation = RoundStatus.class + ) + private RoundStatus roundStatus; + + @Schema( + description = "라운드의 이름/제목. 예: 1주차, 2주차 등", + example = "1주차" + ) + private String roundName; + + @Schema( + description = "정시 출석자 수", + example = "20" + ) + private Long presentCount; + + @Schema( + description = "지각 출석자 수", + example = "5" + ) + private Long lateCount; + + @Schema( + description = "결석자 수", + example = "3" + ) + private Long absentCount; + + @Schema( + description = "총 출석자 수", + example = "28" + ) + private Long totalAttendees; + + /** + * 엔티티를 DTO로 변환 + * roundStatus는 실시간으로 계산되어 반환됨 + * 출석 통계는 단일 루프로 효율적으로 계산됨 + */ + public static AttendanceRoundResponse fromEntity(AttendanceRound round) { + // attendances 리스트가 null일 수 있으므로 방어 + var attendances = round.getAttendances(); + if (attendances == null) { + attendances = List.of(); + } + + // 단일 루프로 모든 통계를 효율적으로 계산 + long presentCount = 0; + long lateCount = 0; + long absentCount = 0; + + for (var attendance : attendances) { + if (attendance.getAttendanceStatus() == AttendanceStatus.PRESENT) { + presentCount++; + } else if (attendance.getAttendanceStatus() == AttendanceStatus.LATE) { + lateCount++; + } else if (attendance.getAttendanceStatus() == AttendanceStatus.ABSENT) { + absentCount++; + } + } + + // 현재 시간 기준으로 라운드 상태를 실시간 계산 + RoundStatus currentStatus = round.calculateCurrentStatus(); + + return AttendanceRoundResponse.builder() + .roundId(round.getRoundId()) + .roundDate(round.getRoundDate()) + .startTime(round.getStartTime()) + .endTime(round.getEndTime()) + .allowedMinutes(round.getAllowedMinutes()) + .roundStatus(currentStatus) + .roundName(round.getRoundName()) + .presentCount(presentCount) + .lateCount(lateCount) + .absentCount(absentCount) + .totalAttendees((long) attendances.size()) + .build(); + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionResponse.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionResponse.java index f3424863..f01a6ac8 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionResponse.java @@ -133,4 +133,4 @@ public class AttendanceSessionResponse { example = "25" ) private Integer participantCount; -} +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionLocationUpdateRequest.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionLocationUpdateRequest.java new file mode 100644 index 00000000..dda3ef25 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionLocationUpdateRequest.java @@ -0,0 +1,20 @@ +package org.sejongisc.backend.attendance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 세션 위치 재설정 요청 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SessionLocationUpdateRequest { + + private Double latitude; // 위도 + + private Double longitude; // 경도 +} 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 29148f45..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 @@ -34,6 +34,11 @@ public class Attendance extends BasePostgresEntity { @JsonBackReference private AttendanceSession attendanceSession; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "round_id", nullable = true) + @JsonBackReference + private AttendanceRound attendanceRound; + @Enumerated(EnumType.STRING) private AttendanceStatus attendanceStatus; @@ -53,6 +58,9 @@ public class Attendance extends BasePostgresEntity { @Column(name = "device_info") private String deviceInfo; + @Column(name = "anonymous_user_name", length = 100) + private String anonymousUserName; + // 지각 여부 계산 / 상태 업데이트 /** diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceRound.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceRound.java new file mode 100644 index 00000000..6e09f17f --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceRound.java @@ -0,0 +1,122 @@ +package org.sejongisc.backend.attendance.entity; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import jakarta.persistence.*; +import lombok.*; +import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 출석 세션 내 개별 라운드(주차) + * + * 예: "금융동아리 2024년 정기 모임" 세션 내 + * - 라운드 1: 2025-11-06, 10:00~11:00 + * - 라운드 2: 2025-11-13, 10:00~11:00 + */ +@Entity +@Getter +@Setter +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class AttendanceRound extends BasePostgresEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "round_id", columnDefinition = "uuid") + private UUID roundId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + @JsonBackReference + private AttendanceSession attendanceSession; + + @Column(nullable = false) + private LocalDate roundDate; // 라운드 날짜 (예: 2025-11-06) + + @Column(nullable = false) + private LocalTime startTime; // 출석 시작 시간 (예: 10:00) + + @Column(nullable = false) + private Integer allowedMinutes; // 출석 인정 시간 (분단위, 예: 30) + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RoundStatus roundStatus; // UPCOMING, ACTIVE, CLOSED + + @Column(name = "round_name", length = 255, nullable = true) + private String roundName; // 라운드 이름 (예: "1차 정기모임", "OT" 등) + + @OneToMany(mappedBy = "attendanceRound", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) + @JsonManagedReference + @Builder.Default + private List attendances = new ArrayList<>(); + + /** + * 현재 라운드 상태 계산 + * - UPCOMING: 라운드 날짜 이전 또는 당일이지만 시작시간 이전 + * - ACTIVE: 시작시간부터 종료시간 사이 + * - CLOSED: 라운드 날짜 이후 또는 당일이지만 종료시간 이후 + */ + public RoundStatus calculateCurrentStatus() { + LocalDate today = LocalDate.now(); + LocalTime now = LocalTime.now(); + + if (today.isBefore(roundDate)) { + return RoundStatus.UPCOMING; + } + + if (today.isAfter(roundDate)) { + return RoundStatus.CLOSED; + } + + // today.equals(roundDate)인 경우 + if (now.isBefore(startTime)) { + return RoundStatus.UPCOMING; + } + + if (now.isAfter(getEndTime())) { + return RoundStatus.CLOSED; + } + + // startTime <= now <= endTime + return RoundStatus.ACTIVE; + } + + /** + * 출석 종료 시간 계산 + */ + public LocalTime getEndTime() { + return startTime.plusMinutes(allowedMinutes != null ? allowedMinutes : 30); + } + + /** + * 해당 라운드에서 출석 가능 여부 확인 + */ + public boolean isCheckInAvailable() { + LocalDate today = LocalDate.now(); + LocalTime now = LocalTime.now(); + + if (!today.equals(roundDate)) { + return false; + } + + return !now.isBefore(startTime) && now.isBefore(getEndTime()); + } + + /** + * 라운드 정보 업데이트 + */ + public void updateRoundInfo(LocalDate newDate, LocalTime newStartTime, Integer newAllowedMinutes) { + this.roundDate = newDate; + this.startTime = newStartTime; + this.allowedMinutes = newAllowedMinutes; + this.roundStatus = calculateCurrentStatus(); + } +} 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 3eaaa2be..48f3f18e 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 @@ -50,6 +50,11 @@ public class AttendanceSession extends BasePostgresEntity { @Enumerated(EnumType.STRING) private SessionStatus status; + @OneToMany(mappedBy = "attendanceSession", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) + @JsonManagedReference + @Builder.Default + private List rounds = new ArrayList<>(); + @OneToMany(mappedBy = "attendanceSession", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @JsonManagedReference @Builder.Default diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/RoundStatus.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/RoundStatus.java new file mode 100644 index 00000000..2e1be229 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/RoundStatus.java @@ -0,0 +1,20 @@ +package org.sejongisc.backend.attendance.entity; + +/** + * 라운드(주차) 상태 + */ +public enum RoundStatus { + UPCOMING("진행 예정"), + ACTIVE("진행 중"), + CLOSED("마감됨"); + + private final String description; + + RoundStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceRepository.java b/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceRepository.java index 36a54070..f44e2362 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceRepository.java @@ -52,4 +52,12 @@ List findByCheckedAtBetween(@Param("startDate") LocalDateTime startD Long countByAttendanceSessionAndStatus(@Param("session") AttendanceSession session, @Param("status") AttendanceStatus status); + // 라운드별 출석 조회 + @Query("SELECT a FROM Attendance a WHERE a.attendanceRound.roundId = :roundId ORDER BY a.checkedAt ASC") + List findByAttendanceRound_RoundId(@Param("roundId") UUID roundId); + + // 라운드별 특정 사용자 출석 확인 + @Query("SELECT a FROM Attendance a WHERE a.attendanceRound.roundId = :roundId AND a.user = :user") + Optional findByAttendanceRound_RoundIdAndUser(@Param("roundId") UUID roundId, @Param("user") User user); + } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceRoundRepository.java b/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceRoundRepository.java new file mode 100644 index 00000000..778a62b3 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceRoundRepository.java @@ -0,0 +1,48 @@ +package org.sejongisc.backend.attendance.repository; + +import org.sejongisc.backend.attendance.entity.AttendanceRound; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface AttendanceRoundRepository extends JpaRepository { + + /** + * 세션 ID로 해당 세션의 모든 라운드 조회 + */ + List findByAttendanceSession_AttendanceSessionIdOrderByRoundDateAsc(UUID sessionId); + + /** + * 세션 ID와 라운드 날짜로 조회 + */ + Optional findByAttendanceSession_AttendanceSessionIdAndRoundDate(UUID sessionId, LocalDate roundDate); + + /** + * 특정 라운드 ID로 조회 (출석 시 필요) + */ + @Query("SELECT r FROM AttendanceRound r " + + "WHERE r.roundId = :roundId") + Optional findRoundById(@Param("roundId") UUID roundId); + + /** + * 특정 세션의 라운드 개수 + */ + long countByAttendanceSession_AttendanceSessionId(UUID sessionId); + + /** + * 특정 세션 내 특정 라운드 번호의 라운드 조회 + * nativeQuery=true를 사용하여 SQL의 LIMIT/OFFSET을 지원 + */ + @Query(value = "SELECT * FROM attendance_round " + + "WHERE attendance_session_id = :sessionId " + + "ORDER BY round_date ASC " + + "LIMIT 1 OFFSET :offset", nativeQuery = true) + Optional findNthRoundInSession(@Param("sessionId") UUID sessionId, @Param("offset") int offset); +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java new file mode 100644 index 00000000..3184006e --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java @@ -0,0 +1,151 @@ +package org.sejongisc.backend.attendance.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.attendance.dto.AttendanceRoundRequest; +import org.sejongisc.backend.attendance.dto.AttendanceRoundResponse; +import org.sejongisc.backend.attendance.entity.AttendanceRound; +import org.sejongisc.backend.attendance.entity.AttendanceSession; +import org.sejongisc.backend.attendance.entity.RoundStatus; +import org.sejongisc.backend.attendance.repository.AttendanceRoundRepository; +import org.sejongisc.backend.attendance.repository.AttendanceSessionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 출석 라운드 서비스 + * 세션 내 주차별 라운드 관리 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class AttendanceRoundService { + + private final AttendanceRoundRepository attendanceRoundRepository; + private final AttendanceSessionRepository attendanceSessionRepository; + + /** + * 라운드 생성 + */ + public AttendanceRoundResponse createRound(UUID sessionId, AttendanceRoundRequest request) { + log.info("📋 라운드 생성 요청: sessionId={}, roundDate={}, startTime={}, allowedMinutes={}", + sessionId, request.getRoundDate(), request.getStartTime(), request.getAllowedMinutes()); + + AttendanceSession session = attendanceSessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다: " + sessionId)); + + try { + // 클라이언트가 보낸 날짜 대신 서버의 현재 날짜를 사용하여 시간대 차이 방지 + LocalDate roundDate = request.getRoundDate(); + if (roundDate == null) { + roundDate = LocalDate.now(); + } + LocalTime requestStartTime = request.getStartTime(); + + log.info("📅 시간대 정보: 클라이언트 roundDate={}, 서버 today={}, 요청 startTime={}", + request.getRoundDate(), roundDate, requestStartTime); + + AttendanceRound round = AttendanceRound.builder() + .attendanceSession(session) + .roundDate(roundDate) + .startTime(requestStartTime) + .allowedMinutes(request.getAllowedMinutes() != null ? request.getAllowedMinutes() : 30) + .roundStatus(RoundStatus.UPCOMING) + .build(); + + log.info("🔨 라운드 엔티티 생성: roundDate={}, startTime={}, allowedMinutes={}", + round.getRoundDate(), round.getStartTime(), round.getAllowedMinutes()); + + RoundStatus status = round.calculateCurrentStatus(); + round.setRoundStatus(status); + + log.info("📊 라운드 상태 계산: 현재시간={}, 라운드시작={}, 계산된상태={}, 종료시간={}", + LocalTime.now(), round.getStartTime(), status, round.getEndTime()); + + AttendanceRound saved = attendanceRoundRepository.save(round); + session.getRounds().add(saved); + + log.info("✅ 라운드 생성 완료 - sessionId: {}, roundId: {}, roundDate: {}, roundStatus: {}", + sessionId, saved.getRoundId(), saved.getRoundDate(), saved.getRoundStatus()); + return AttendanceRoundResponse.fromEntity(saved); + } catch (Exception e) { + log.error("❌ 라운드 생성 중 오류 발생: sessionId={}, error={}", sessionId, e.getMessage(), e); + throw new RuntimeException("라운드 생성에 실패했습니다: " + e.getMessage(), e); + } + } + + /** + * 라운드 조회 (개별) + */ + @Transactional(readOnly = true) + public AttendanceRoundResponse getRound(UUID roundId) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + roundId)); + + return AttendanceRoundResponse.fromEntity(round); + } + + /** + * 세션 내 라운드 목록 조회 + */ + @Transactional(readOnly = true) + public List getRoundsBySession(UUID sessionId) { + List rounds = attendanceRoundRepository + .findByAttendanceSession_AttendanceSessionIdOrderByRoundDateAsc(sessionId); + + return rounds.stream() + .map(AttendanceRoundResponse::fromEntity) + .collect(Collectors.toList()); + } + + /** + * 라운드 정보 수정 + */ + public AttendanceRoundResponse updateRound(UUID roundId, AttendanceRoundRequest request) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + roundId)); + + round.updateRoundInfo( + request.getRoundDate(), + request.getStartTime(), + request.getAllowedMinutes() + ); + + AttendanceRound updated = attendanceRoundRepository.save(round); + log.info("라운드 수정 완료 - roundId: {}", roundId); + return AttendanceRoundResponse.fromEntity(updated); + } + + /** + * 라운드 삭제 + */ + public void deleteRound(UUID roundId) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + roundId)); + + AttendanceSession session = round.getAttendanceSession(); + session.getRounds().remove(round); + + attendanceRoundRepository.delete(round); + log.info("라운드 삭제 완료 - roundId: {}", roundId); + } + + /** + * 특정 날짜의 라운드 조회 + */ + @Transactional(readOnly = true) + public AttendanceRoundResponse getRoundByDate(UUID sessionId, LocalDate date) { + AttendanceRound round = attendanceRoundRepository + .findByAttendanceSession_AttendanceSessionIdAndRoundDate(sessionId, date) + .orElseThrow(() -> new IllegalArgumentException("해당 날짜의 라운드를 찾을 수 없습니다")); + + return AttendanceRoundResponse.fromEntity(round); + } +} 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 f5a442bc..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 @@ -2,13 +2,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.attendance.dto.AttendanceCheckInRequest; +import org.sejongisc.backend.attendance.dto.AttendanceCheckInResponse; import org.sejongisc.backend.attendance.dto.AttendanceRequest; import org.sejongisc.backend.attendance.dto.AttendanceResponse; -import org.sejongisc.backend.attendance.entity.Attendance; -import org.sejongisc.backend.attendance.entity.AttendanceSession; -import org.sejongisc.backend.attendance.entity.AttendanceStatus; -import org.sejongisc.backend.attendance.entity.Location; +import org.sejongisc.backend.attendance.entity.*; import org.sejongisc.backend.attendance.repository.AttendanceRepository; +import org.sejongisc.backend.attendance.repository.AttendanceRoundRepository; import org.sejongisc.backend.attendance.repository.AttendanceSessionRepository; import org.sejongisc.backend.user.dao.UserRepository; import org.sejongisc.backend.user.entity.User; @@ -28,6 +28,7 @@ public class AttendanceService { private final AttendanceRepository attendanceRepository; private final AttendanceSessionRepository attendanceSessionRepository; + private final AttendanceRoundRepository attendanceRoundRepository; private final UserRepository userRepository; @@ -107,6 +108,169 @@ public AttendanceResponse checkIn(UUID sessionId, AttendanceRequest request, UUI return convertToResponse(attendance); } + /** + * 라운드 기반 출석 체크인 처리 + * - 특정 라운드의 시간 및 위치 검증 + * - 지각 판별 및 출석 상태 결정 + */ + public AttendanceCheckInResponse checkInByRound(AttendanceCheckInRequest request, UUID userId) { + // 사용자가 존재하면 조회, 없으면 null (익명 사용자 지원) + User user = userRepository.findById(userId).orElse(null); + + AttendanceRound round = attendanceRoundRepository.findRoundById(request.getRoundId()) + .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + request.getRoundId())); + + AttendanceSession session = round.getAttendanceSession(); + + // 익명사용자의 이름 결정 + String anonymousName = null; + if (user == null) { + // 사용자가 이름을 입력한 경우 사용 + if (request.getUserName() != null && !request.getUserName().trim().isEmpty()) { + anonymousName = request.getUserName(); + } else { + // 이름 미입력 시 자동 생성 (익명사용자-UUID의 처음 8글자) + anonymousName = "익명사용자-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + } + + String userName = user != null ? user.getName() : anonymousName; + + log.info("라운드 출석 체크인 시작: 사용자={}, 라운드ID={}, 날짜={}, 익명여부={}", + userName, request.getRoundId(), round.getRoundDate(), user == null); + + // 1. 라운드 시간 검증 - 상세 로깅 + java.time.LocalTime checkTime = java.time.LocalTime.now(); + java.time.LocalDate checkDate = java.time.LocalDate.now(); + java.time.LocalTime endTime = round.getEndTime(); + java.time.LocalTime startTime = round.getStartTime(); + + // 날짜 검증 + boolean dateMatch = checkDate.equals(round.getRoundDate()); + // 시간 검증: startTime <= now < endTime + boolean timeInRange = !checkTime.isBefore(startTime) && checkTime.isBefore(endTime); + + log.info("📋 시간 검증 상세: 현재날짜={}, 라운드날짜={}, 날짜일치={} | 현재시간={}, 시작={}, 종료={}, 시간범위내={}", + checkDate, round.getRoundDate(), dateMatch, checkTime, startTime, endTime, timeInRange); + + if (!round.isCheckInAvailable()) { + log.warn("❌ 출석 시간 초과: 라운드ID={}, 사용자={}, 현재시간={}, 시작시간={}, 종료시간={}, 현재날짜={}, 라운드날짜={}, 이유: 날짜일치={}|시간범위={}", + request.getRoundId(), userName, checkTime, startTime, endTime, checkDate, round.getRoundDate(), dateMatch, timeInRange); + return AttendanceCheckInResponse.builder() + .roundId(request.getRoundId()) + .success(false) + .failureReason("출석 시간 초과") + .build(); + } + + log.info("✅ 시간 검증 성공: 라운드ID={}, 사용자={}, 라운드날짜={}, 라운드시작={}, 종료={}, 허용분={}, 현재시간={}", + request.getRoundId(), userName, round.getRoundDate(), startTime, endTime, round.getAllowedMinutes(), checkTime); + + // 2. 중복 출석 확인 (인증된 사용자 또는 익명사용자 모두) + if (user != null) { + // 인증된 사용자: user ID로 중복 체크 + boolean alreadyCheckedIn = attendanceRepository.findByAttendanceRound_RoundIdAndUser(request.getRoundId(), user) + .isPresent(); + if (alreadyCheckedIn) { + log.warn("중복 출석 시도: 라운드ID={}, 사용자={}", request.getRoundId(), userName); + return AttendanceCheckInResponse.builder() + .roundId(request.getRoundId()) + .success(false) + .failureReason("이미 출석 체크인하셨습니다") + .build(); + } + } else if (request.getUserName() != null && !request.getUserName().trim().isEmpty()) { + // 익명 사용자: 입력한 이름으로 중복 체크 + List existingAttendances = attendanceRepository.findByAttendanceRound_RoundId(request.getRoundId()); + boolean alreadyCheckedIn = existingAttendances.stream() + .anyMatch(a -> a.getUser() == null && + request.getUserName().equalsIgnoreCase(a.getAnonymousUserName())); + if (alreadyCheckedIn) { + log.warn("익명사용자 중복 출석 시도: 라운드ID={}, 이름={}", request.getRoundId(), request.getUserName()); + return AttendanceCheckInResponse.builder() + .roundId(request.getRoundId()) + .success(false) + .failureReason("이미 출석 체크인하셨습니다") + .build(); + } + } + + // 3. 위치 검증 (세션에 위치 정보가 있는 경우) + Location userLocation = null; + if (session.getLocation() != null) { + if (request.getLatitude() == null || request.getLongitude() == null) { + log.warn("위치 정보 누락: 라운드ID={}, 사용자={}", request.getRoundId(), userName); + return AttendanceCheckInResponse.builder() + .roundId(request.getRoundId()) + .success(false) + .failureReason("위치 정보가 필요합니다") + .build(); + } + + userLocation = Location.builder() + .lat(request.getLatitude()) + .lng(request.getLongitude()) + .build(); + + if (!session.getLocation().isWithRange(userLocation)) { + log.warn("위치 불일치: 라운드ID={}, 사용자={}, 거리 초과", + request.getRoundId(), userName); + return AttendanceCheckInResponse.builder() + .roundId(request.getRoundId()) + .success(false) + .failureReason("위치 불일치 - 허용 범위를 벗어났습니다") + .build(); + } + } + + // 4. 출석 상태 판별 (정상/지각) + java.time.LocalTime now = java.time.LocalTime.now(); + java.time.LocalTime lateThreshold = round.getStartTime().plusMinutes(5); + AttendanceStatus status = now.isAfter(lateThreshold) ? + AttendanceStatus.LATE : AttendanceStatus.PRESENT; + + log.info("📊 출석 상태 판별: 현재시간={}, 시작시간={}, 지각기준={}, 판별상태={}", + now, round.getStartTime(), lateThreshold, status); + + // 5. 출석 기록 저장 + Attendance attendance = Attendance.builder() + .user(user) // null 가능 (익명 사용자) + .attendanceSession(session) + .attendanceRound(round) + .attendanceStatus(status) + .checkedAt(java.time.LocalDateTime.now()) + .awardedPoints(session.getRewardPoints()) + .checkInLocation(userLocation) + .anonymousUserName(user == null ? anonymousName : null) // 익명사용자일 경우 이름 저장 (입력 또는 자동생성) + .build(); + + log.info("💾 Attendance 객체 생성 완료: 사용자={}, 라운드ID={}, 상태={}, 체크인시간={}, 익명이름={}", + userName, request.getRoundId(), status, attendance.getCheckedAt(), anonymousName); + + attendance = attendanceRepository.save(attendance); + + log.info("✅ Attendance 저장 완료: attendanceId={}, 사용자={}, 라운드ID={}, 상태={}", + attendance.getAttendanceId(), userName, request.getRoundId(), status); + + round.getAttendances().add(attendance); + + log.info("✅ 라운드 출석 체크인 완료: 사용자={}, 상태={}, 저장된ID={}", userName, status, attendance.getAttendanceId()); + + long remainingSeconds = java.time.Duration.between( + java.time.LocalTime.now(), + round.getEndTime() + ).getSeconds(); + + return AttendanceCheckInResponse.builder() + .roundId(request.getRoundId()) + .success(true) + .status(status.toString()) + .checkedAt(attendance.getCheckedAt()) + .awardedPoints(attendance.getAwardedPoints()) + .remainingSeconds(Math.max(0, remainingSeconds)) + .build(); + } + /** * 세션별 출석 목록 조회 * - 관리자가 특정 세션의 모든 출석자 확인 @@ -173,6 +337,36 @@ public AttendanceResponse updateAttendanceStatus(UUID sessionId, UUID memberId, return convertToResponse(attendance); } + /** + * 라운드별 출석 목록 조회 + * - 특정 라운드의 모든 출석 기록 조회 + * - 출석 시간 순으로 정렬 + */ + @Transactional(readOnly = true) + public List getAttendancesByRound(java.util.UUID roundId) { + log.info("📋 라운드별 출석 명단 조회 시작: roundId={}", roundId); + + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + roundId)); + + List attendances = attendanceRepository.findByAttendanceRound_RoundId(roundId); + + log.info("📊 라운드별 출석 명단 조회 결과: roundId={}, 출석인원={}, 라운드날짜={}, 라운드상태={}", + roundId, attendances.size(), round.getRoundDate(), round.getRoundStatus()); + + for (Attendance a : attendances) { + log.info(" - 출석기록: 사용자={}, 상태={}, 체크인={}, 포인트={}", + a.getUser() != null ? a.getUser().getName() : "익명", + a.getAttendanceStatus(), + a.getCheckedAt(), + a.getAwardedPoints()); + } + + return attendances.stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + /** * Attendance 엔티티를 AttendanceResponse DTO로 변환 * - 엔티티의 모든 필드를 Response 형태로 매핑 @@ -181,9 +375,12 @@ public AttendanceResponse updateAttendanceStatus(UUID sessionId, UUID memberId, private AttendanceResponse convertToResponse(Attendance attendance) { return AttendanceResponse.builder() .attendanceId(attendance.getAttendanceId()) - .userId(attendance.getUser().getUserId()) - .userName(attendance.getUser().getName()) + .userId(attendance.getUser() != null ? attendance.getUser().getUserId() : null) + .userName(attendance.getUser() != null ? attendance.getUser().getName() : + (attendance.getAnonymousUserName() != null ? attendance.getAnonymousUserName() : "익명사용자")) .attendanceSessionId(attendance.getAttendanceSession().getAttendanceSessionId()) + .attendanceRoundId(attendance.getAttendanceRound() != null ? + attendance.getAttendanceRound().getRoundId() : null) .attendanceStatus(attendance.getAttendanceStatus()) .checkedAt(attendance.getCheckedAt()) .awardedPoints(attendance.getAwardedPoints()) 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 63c5dd32..068d2984 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 @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.attendance.dto.AttendanceSessionRequest; import org.sejongisc.backend.attendance.dto.AttendanceSessionResponse; +import org.sejongisc.backend.attendance.dto.SessionLocationUpdateRequest; import org.sejongisc.backend.attendance.entity.AttendanceSession; import org.sejongisc.backend.attendance.entity.Location; import org.sejongisc.backend.attendance.entity.SessionStatus; @@ -277,6 +278,36 @@ private String generateUniqueCode() { return code; } + /** + * 세션 위치 재설정 + * - 기존 위치 정보를 새로운 위치로 업데이트 + * - 반경은 기존 값 유지 또는 0으로 설정 + */ + public AttendanceSessionResponse updateSessionLocation(UUID sessionId, SessionLocationUpdateRequest request) { + log.info("세션 위치 재설정 시작: 세션ID={}, 위도={}, 경도={}", + sessionId, request.getLatitude(), request.getLongitude()); + + AttendanceSession session = attendanceSessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); + + Location newLocation = Location.builder() + .lat(request.getLatitude()) + .lng(request.getLongitude()) + .radiusMeters(session.getLocation() != null ? + session.getLocation().getRadiusMeters() : 100) + .build(); + + session = session.toBuilder() + .location(newLocation) + .build(); + + session = attendanceSessionRepository.save(session); + + log.info("세션 위치 재설정 완료: 세션ID={}", sessionId); + + return convertToResponse(session); + } + /** * 6자리 랜덤 숫자 코드 생성 * - 000000 ~ 999999 범위 내 랜덤 생성