Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<AttendanceResponse> 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);
}

/**
* 세션별 출석 목록 조회(관리자용)
* - 특정 세션의 모든 출석 기록 조회
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class AttendanceRoundResponse {
description = "라운드의 고유 ID",
example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
)
private UUID id;
private UUID roundId;

@Schema(
description = "라운드 진행 날짜",
Expand All @@ -35,7 +35,7 @@ public class AttendanceRoundResponse {
format = "date"
)
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate date;
private LocalDate roundDate;

@Schema(
description = "라운드 출석 시작 시간",
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import jakarta.validation.constraints.*;
import lombok.*;

import java.time.LocalTime;
import java.time.LocalDateTime;

@Getter
@Builder
Expand All @@ -13,7 +13,7 @@
@Schema(
title = "출석 세션 생성/수정 요청",
description = "관리자가 출석 세션을 생성하거나 수정할 때 사용하는 요청 객체. " +
"세션의 기본 정보, 기본 시간, 위치, 포인트 설정을 포함합니다."
"세션의 기본 정보, 시간, 위치, 포인트 설정을 포함합니다."
)
public class AttendanceSessionRequest {

Expand All @@ -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 = "출석 완료 시 지급할 포인트",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"
Expand All @@ -56,23 +55,46 @@ public class AttendanceSession extends BasePostgresEntity {
private List<Attendance> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
* 라운드 기반 출석 체크인 처리
* - 특정 라운드의 시간 및 위치 검증
Expand Down
Loading