Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,22 @@ public class AttendanceController {

/**
* 세션별 출석 목록 조회(관리자용)
* @deprecated 라운드 기반 조회로 변경되었습니다.
* GET /api/attendance/rounds/{roundId}/attendances 를 사용하세요.
* - 특정 세션의 모든 출석 기록 조회
* - 출석 시간 순으로 정렬
*/
@Operation(
summary = "세션별 출석 목록 조회",
description = "특정 세션에 참가한 모든 학생의 출석 기록을 조회합니다. (관리자 전용) " +
"출석 시간 순으로 정렬되며, 각 학생의 상태, 체크인 시간, 포인트 등이 포함됩니다."
description = "⚠️ [DEPRECATED] 라운드 기반 조회로 변경되었습니다. " +
"GET /api/attendance/rounds/{roundId}/attendances 를 사용하세요. " +
"특정 세션에 참가한 모든 학생의 출석 기록을 조회합니다. (관리자 전용) " +
"출석 시간 순으로 정렬되며, 각 학생의 상태, 체크인 시간, 포인트 등이 포함됩니다.",
deprecated = true
)
@GetMapping("/sessions/{sessionId}/attendances")
@PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')")
@Deprecated(since = "2.0", forRemoval = true)
public ResponseEntity<List<AttendanceResponse>> getAttendancesBySession(@PathVariable UUID sessionId) {
log.info("세션별 출석 목록 조회: 세션ID={}", sessionId);

Expand Down Expand Up @@ -69,17 +75,23 @@ public ResponseEntity<List<AttendanceResponse>> getMyAttendances(

/**
* 출석 상태 수정(관리자용)
* @deprecated 라운드 기반 수정으로 변경되었습니다.
* PUT /api/attendance/rounds/{roundId}/attendances/{userId} 를 사용하세요.
* - PRESENT/LATE/ABSENT 등으로 상태 변경
* - 수정 사유 기록 가능
*/
@Operation(
summary = "출석 상태 수정",
description = "특정 학생의 출석 상태를 변경합니다. (관리자 전용) " +
description = "⚠️ [DEPRECATED] 라운드 기반 수정으로 변경되었습니다. " +
"PUT /api/attendance/rounds/{roundId}/attendances/{userId} 를 사용하세요. " +
"특정 학생의 출석 상태를 변경합니다. (관리자 전용) " +
"PRESENT(출석), LATE(지각), ABSENT(결석), EXCUSED(사유결석) 등의 상태로 변경 가능하며, " +
"변경 사유를 함께 기록할 수 있습니다."
"변경 사유를 함께 기록할 수 있습니다.",
deprecated = true
)
@PostMapping("/sessions/{sessionId}/attendances/{memberId}")
@PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')")
@Deprecated(since = "2.0", forRemoval = true)
public ResponseEntity<AttendanceResponse> updateAttendanceStatus(
@PathVariable UUID sessionId,
@PathVariable UUID memberId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
@Schema(
title = "출석 체크인 요청",
description = "라운드에 출석 체크인을 기록할 때 사용하는 요청 객체. " +
"라운드 ID, 현재 위치(GPS), 사용자 이름을 포함합니다."
"라운드 ID와 현재 위치(GPS) 정보를 포함합니다."
)
public class AttendanceCheckInRequest {

Expand All @@ -32,32 +32,23 @@ public class AttendanceCheckInRequest {
)
private UUID roundId;

@NotNull(message = "위도는 필수입니다")
@DecimalMin(value = "-90.0", message = "위도는 -90도 이상이어야 합니다")
@DecimalMax(value = "90.0", message = "위도는 90도 이하여야 합니다")
@Schema(
description = "현재 사용자의 위치 위도 (WGS84 좌표계)",
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 좌표계)",
description = "현재 사용자의 위치 경도 (WGS84 좌표계). 세션에 위치 정보가 있으면 필수입니다.",
example = "127.0276",
minimum = "-180.0",
maximum = "180.0"
)
private Double longitude;

@Schema(
description = "익명 사용자의 이름 (선택사항). 입력하지 않으면 '익명사용자-{UUID}'로 자동 생성됨.",
example = "김철수",
nullable = true
)
private String userName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public interface SessionUserRepository extends JpaRepository<SessionUser, UUID>
/**
* 세션에 특정 사용자가 참여하는지 확인
*/
@Query("SELECT CASE WHEN COUNT(su) > 0 THEN true ELSE false END FROM SessionUser su WHERE su.attendanceSession.attendanceSessionId = :sessionId AND su.user.userId = :userId")
@Query("SELECT COUNT(su) > 0 FROM SessionUser su WHERE su.attendanceSession.attendanceSessionId = :sessionId AND su.user.userId = :userId")
boolean existsBySessionIdAndUserId(@Param("sessionId") UUID sessionId, @Param("userId") UUID userId);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
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;
Expand All @@ -35,92 +37,68 @@ public class AttendanceService {
* - 지각 판별 및 출석 상태 결정
*/
public AttendanceCheckInResponse checkInByRound(AttendanceCheckInRequest request, UUID userId) {
// 사용자가 존재하면 조회, 없으면 null (익명 사용자 지원)
User user = userRepository.findById(userId).orElse(null);
// 사용자 조회
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId));

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();
}
}
log.info("라운드 출석 체크인 시작: 사용자={}, 라운드ID={}, 날짜={}",
user.getName(), request.getRoundId(), round.getRoundDate());

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();
// 1. 라운드 시간 검증 - 통일된 로직
LocalDate checkDate = LocalDate.now();
LocalTime checkTime = LocalTime.now();
LocalTime startTime = round.getStartTime();
LocalTime endTime = round.getEndTime();
LocalTime lateThreshold = startTime.plusMinutes(5);

// 날짜 검증
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 (!checkDate.equals(round.getRoundDate())) {
log.warn("❌ 출석 날짜 불일치: 라운드ID={}, 사용자={}, 현재날짜={}, 라운드날짜={}",
request.getRoundId(), user.getName(), checkDate, round.getRoundDate());
return AttendanceCheckInResponse.builder()
.roundId(request.getRoundId())
.success(false)
.failureReason("출석 날짜가 맞지 않습니다")
.build();
}

if (!round.isCheckInAvailable()) {
log.warn("❌ 출석 시간 초과: 라운드ID={}, 사용자={}, 현재시간={}, 시작시간={}, 종료시간={}, 현재날짜={}, 라운드날짜={}, 이유: 날짜일치={}|시간범위={}",
request.getRoundId(), userName, checkTime, startTime, endTime, checkDate, round.getRoundDate(), dateMatch, timeInRange);
// 시간 범위 검증: startTime <= now < endTime
boolean isWithinTimeWindow = !checkTime.isBefore(startTime) && checkTime.isBefore(endTime);
if (!isWithinTimeWindow) {
log.warn("❌ 출석 시간 초과: 라운드ID={}, 사용자={}, 현재시간={}, 시작={}, 종료={}",
request.getRoundId(), user.getName(), checkTime, startTime, endTime);
return AttendanceCheckInResponse.builder()
.roundId(request.getRoundId())
.success(false)
.failureReason("출석 시간 초과")
.build();
}

log.info("✅ 시간 검증 성공: 라운드ID={}, 사용자={}, 라운드날짜={}, 라운드시작={}, 종료={}, 허용분={}, 현재시간={}",
request.getRoundId(), userName, round.getRoundDate(), startTime, endTime, round.getAllowedMinutes(), checkTime);
log.info("✅ 시간 검증 성공: 라운드ID={}, 사용자={}, 시간={}, 범위=[{}~{}]",
request.getRoundId(), user.getName(), checkTime, startTime, endTime);

// 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<Attendance> 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();
}
// 2. 중복 출석 확인
boolean alreadyCheckedIn = attendanceRepository.findByAttendanceRound_RoundIdAndUser(request.getRoundId(), user)
.isPresent();
if (alreadyCheckedIn) {
log.warn("중복 출석 시도: 라운드ID={}, 사용자={}", request.getRoundId(), user.getName());
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);
log.warn("위치 정보 누락: 라운드ID={}, 사용자={}", request.getRoundId(), user.getName());
return AttendanceCheckInResponse.builder()
.roundId(request.getRoundId())
.success(false)
Expand All @@ -135,7 +113,7 @@ public AttendanceCheckInResponse checkInByRound(AttendanceCheckInRequest request

if (!session.getLocation().isWithRange(userLocation)) {
log.warn("위치 불일치: 라운드ID={}, 사용자={}, 거리 초과",
request.getRoundId(), userName);
request.getRoundId(), user.getName());
return AttendanceCheckInResponse.builder()
.roundId(request.getRoundId())
.success(false)
Expand All @@ -145,41 +123,39 @@ public AttendanceCheckInResponse checkInByRound(AttendanceCheckInRequest request
}

// 4. 출석 상태 판별 (정상/지각)
java.time.LocalTime now = java.time.LocalTime.now();
java.time.LocalTime lateThreshold = round.getStartTime().plusMinutes(5);
AttendanceStatus status = now.isAfter(lateThreshold) ?
// 지각 기준: 시작시간 + 5분 이후면 LATE
AttendanceStatus status = checkTime.isAfter(lateThreshold) ?
AttendanceStatus.LATE : AttendanceStatus.PRESENT;

log.info("📊 출석 상태 판별: 현재시간={}, 시작시간={}, 지각기준={}, 판별상태={}",
now, round.getStartTime(), lateThreshold, status);
log.info("📊 출석 상태 판별: 현재시간={}, 시작={}, 지각기준={}, 판별상태={}",
checkTime, startTime, lateThreshold, status);

// 5. 출석 기록 저장
Attendance attendance = Attendance.builder()
.user(user) // null 가능 (익명 사용자)
.user(user)
.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);
log.info("💾 Attendance 객체 생성 완료: 사용자={}, 라운드ID={}, 상태={}, 체크인시간={}",
user.getName(), request.getRoundId(), status, attendance.getCheckedAt());

attendance = attendanceRepository.save(attendance);

log.info("✅ Attendance 저장 완료: attendanceId={}, 사용자={}, 라운드ID={}, 상태={}",
attendance.getAttendanceId(), userName, request.getRoundId(), status);
attendance.getAttendanceId(), user.getName(), request.getRoundId(), status);

round.getAttendances().add(attendance);

log.info("✅ 라운드 출석 체크인 완료: 사용자={}, 상태={}, 저장된ID={}", userName, status, attendance.getAttendanceId());
log.info("✅ 라운드 출석 체크인 완료: 사용자={}, 상태={}, 저장된ID={}", user.getName(), status, attendance.getAttendanceId());

long remainingSeconds = java.time.Duration.between(
java.time.LocalTime.now(),
round.getEndTime()
checkTime,
endTime
).getSeconds();

return AttendanceCheckInResponse.builder()
Expand Down Expand Up @@ -356,9 +332,8 @@ public AttendanceResponse updateAttendanceStatusByRound(UUID roundId, UUID userI
private AttendanceResponse convertToResponse(Attendance attendance) {
return AttendanceResponse.builder()
.attendanceId(attendance.getAttendanceId())
.userId(attendance.getUser() != null ? attendance.getUser().getUserId() : null)
.userName(attendance.getUser() != null ? attendance.getUser().getName() :
(attendance.getAnonymousUserName() != null ? attendance.getAnonymousUserName() : "익명사용자"))
.userId(attendance.getUser().getUserId())
.userName(attendance.getUser().getName())
.attendanceSessionId(attendance.getAttendanceSession().getAttendanceSessionId())
.attendanceRoundId(attendance.getAttendanceRound() != null ?
attendance.getAttendanceRound().getRoundId() : null)
Expand Down