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 @@ -55,9 +55,9 @@ public ResponseEntity<AttendanceRoundResponse> createRound(
@Valid @RequestBody AttendanceRoundRequest request) {
log.info("📋 라운드 생성 요청 도착:");
log.info(" - sessionId: {}", sessionId);
log.info(" - date: {} (타입: {})", request.getDate(), request.getDate() != null ? request.getDate().getClass().getSimpleName() : "null");
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(" - availableMinutes: {}", request.getAvailableMinutes());
log.info(" - allowedMinutes: {}", request.getAllowedMinutes());

if (request.getStartTime() != null) {
log.info(" - startTime 상세: 시간={}, 분={}, 초={}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ public class AttendanceSessionController {
* 출석 세션 생성 (관리자용)
* - 6자리 랜덤 코드 자동 생성
* - GPS 위치 및 반경 설정
* - 기본 시작 시간 및 출석 인정 시간 설정
* - 시간 윈도우 설정
*/
@Operation(
summary = "출석 세션 생성",
description = "새로운 출석 세션을 생성합니다. (관리자 전용) " +
"6자리 랜덤 코드가 자동 생성되며, GPS 위치 정보, 기본 시작 시간, " +
"출석 인정 시간, 보상 포인트 등을 설정할 수 있습니다."
"6자리 랜덤 코드가 자동 생성되며, GPS 위치 정보, 시간 윈도우, " +
"보상 포인트 등을 설정할 수 있습니다."
)
@PostMapping
@PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')")
Expand Down Expand Up @@ -132,13 +132,13 @@ public ResponseEntity<List<AttendanceSessionResponse>> getActiveSessions() {

/**
* 세션 정보 수정 (관리자용)
* - 제목, 기본 시간, 위치, 반경 등 수정 가능
* - 제목, 시간, 위치, 반경 등 수정 가능
* - 코드는 변경 불가
*/
@Operation(
summary = "세션 정보 수정",
description = "세션의 기본 정보를 수정합니다. (관리자 전용) " +
"제목, 기본 시작 시간, 출석 인정 시간, GPS 위치, 반경, 포인트 등을 수정할 수 있으며, " +
"제목, 태그, 시간, GPS 위치, 반경, 포인트 등을 수정할 수 있으며, " +
"6자리 코드는 변경할 수 없습니다."
)
@PutMapping("/{sessionId}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public class AttendanceRoundRequest {
type = "string",
format = "date"
)
private LocalDate date;
private LocalDate roundDate;

@NotNull(message = "시작 시간은 필수입니다")
@Schema(
Expand All @@ -60,5 +60,5 @@ public class AttendanceRoundRequest {
minimum = "1",
maximum = "120"
)
private Integer availableMinutes;
private Integer allowedMinutes;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -69,7 +69,7 @@ public static AttendanceRoundResponse fromEntity(AttendanceRound round) {

return AttendanceRoundResponse.builder()
.roundId(round.getRoundId())
.date(round.getRoundDate())
.roundDate(round.getRoundDate())
.startTime(round.getStartTime())
.availableMinutes(round.getAllowedMinutes())
.status(statusString)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package org.sejongisc.backend.attendance.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import lombok.*;

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

@Getter
@Builder
Expand All @@ -20,34 +19,33 @@ public class AttendanceSessionRequest {

@Schema(
description = "세션의 제목/이름",
example = "금융 IT팀 세션",
example = "2024년 10월 동아리 정기 모임",
maxLength = 100
)
@NotBlank(message = "제목은 필수입니다")
@Size(max = 100, message = "제목은 100자 이하여야 합니다")
private String title;

@Schema(
description = "세션의 기본 시작 시간 (HH:MM:SS 형식). " +
"모든 라운드는 이 시간을 기본값으로 사용합니다.",
example = "18:30:00",
description = "세션 시작 시간 (ISO 8601 형식). 현재 시간 이후여야 합니다.",
example = "2024-11-15T14:00:00",
type = "string",
format = "time"
format = "date-time"
)
@NotNull(message = "시작 시간은 필수입니다")
@JsonFormat(pattern = "HH:mm:ss")
private LocalTime defaultStartTime;
@Future(message = "시작 시간은 현재 시간 이후여야 합니다")
private LocalDateTime startsAt;

@Schema(
description = "출석 인정 시간 (분 단위). " +
"범위: 1분 ~ 240분(4시간)",
example = "30",
minimum = "1",
maximum = "240"
description = "출석 체크인이 가능한 시간 윈도우 (초 단위). " +
"범위: 300초(5분) ~ 14400초(4시간)",
example = "1800",
minimum = "300",
maximum = "14400"
)
@Min(value = 1, message = "최소 1분 이상이어야 합니다")
@Max(value = 240, message = "최대 240분(4시간) 설정 가능합니다")
private Integer defaultAvailableMinutes;
@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,25 +65,12 @@ public class Attendance extends BasePostgresEntity {

/**
* 지각 여부 판단
* - 라운드 기반: 라운드 시작 시간 이후이면 지각
* - 세션 기반: 라운드 없이 진행되는 경우는 확인 불가
*/
public boolean isLate() {
if (checkedAt == null) {
if (checkedAt == null || attendanceSession.getStartsAt() == null) {
return false;
}

// 라운드 기반 체크인인 경우
if (attendanceRound != null) {
LocalDateTime roundStartTime = LocalDateTime.of(
attendanceRound.getRoundDate(),
attendanceRound.getStartTime()
);
return checkedAt.isAfter(roundStartTime);
}

// 라운드 없는 경우 지각 판단 불가
return false;
return checkedAt.isAfter(attendanceSession.getStartsAt());
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
package org.sejongisc.backend.attendance.entity;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import lombok.*;
import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity;

import java.time.LocalTime;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
Expand All @@ -25,14 +24,13 @@ public class AttendanceSession extends BasePostgresEntity {
private UUID attendanceSessionId;

@Column(nullable = false)
private String title; // "금융 IT팀 세션"
private String title; // "세투연 9/17"

@Column(name = "default_start_time", nullable = false)
@JsonFormat(pattern = "HH:mm:ss")
private LocalTime defaultStartTime; // 기본 시작 시간 (시간만) - 18:30:00
@Column(name = "starts_at", nullable = false)
private LocalDateTime startsAt; // 세션 시작 시간

@Column(name = "default_available_minutes")
private Integer defaultAvailableMinutes; // 출석 인정 시간() - 30분
@Column(name = "window_seconds")
private Integer windowSeconds; // 체크인 가능 시간() - 1800 = 30분

@Column(unique = true, length = 6)
private String code; // 6자리 출석 코드 "942715"
Expand All @@ -57,19 +55,46 @@ public class AttendanceSession extends BasePostgresEntity {
private List<Attendance> attendances = new ArrayList<>();

/**
* 세션 상태를 반환합니다.
* - UPCOMING: 활성화되지 않은 상태
* - OPEN: 관리자가 활성화한 상태 (체크인 가능)
* - CLOSED: 관리자가 종료한 상태 (체크인 불가)
* 현재 세션 상태 계산
*/
public SessionStatus getCurrentStatus() {
return this.status;
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() {
return SessionStatus.OPEN.equals(this.status);
LocalDateTime now = LocalDateTime.now();
return now.isAfter(startsAt) && now.isBefore(getEndsAt());
}

/**
* 세션 종료 시간 계산
*/
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 @@ -35,25 +35,28 @@ public class AttendanceRoundService {
* 라운드 생성
*/
public AttendanceRoundResponse createRound(UUID sessionId, AttendanceRoundRequest request) {
log.info("📋 라운드 생성 요청: sessionId={}, date={}, startTime={}, availableMinutes={}",
sessionId, request.getDate(), request.getStartTime(), request.getAvailableMinutes());
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.getDate() != null ? request.getDate() : LocalDate.now();
// 클라이언트가 보낸 날짜 대신 서버의 현재 날짜를 사용하여 시간대 차이 방지
LocalDate roundDate = request.getRoundDate();
if (roundDate == null) {
roundDate = LocalDate.now();
}
LocalTime requestStartTime = request.getStartTime();

log.info("📅 시간대 정보: 클라이언트 date={}, 사용할 roundDate={}, 요청 startTime={}",
request.getDate(), roundDate, requestStartTime);
log.info("📅 시간대 정보: 클라이언트 roundDate={}, 서버 today={}, 요청 startTime={}",
request.getRoundDate(), roundDate, requestStartTime);

AttendanceRound round = AttendanceRound.builder()
.attendanceSession(session)
.roundDate(roundDate) // 클라이언트 날짜를 우선 사용
.roundDate(roundDate)
.startTime(requestStartTime)
.allowedMinutes(request.getAvailableMinutes() != null ? request.getAvailableMinutes() : 30)
.allowedMinutes(request.getAllowedMinutes() != null ? request.getAllowedMinutes() : 30)
.roundStatus(RoundStatus.UPCOMING)
.build();

Expand Down Expand Up @@ -112,9 +115,9 @@ public AttendanceRoundResponse updateRound(UUID roundId, AttendanceRoundRequest
.orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + roundId));

round.updateRoundInfo(
request.getDate(),
request.getRoundDate(),
request.getStartTime(),
request.getAvailableMinutes()
request.getAllowedMinutes()
);

AttendanceRound updated = attendanceRoundRepository.save(round);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,27 +75,24 @@ public AttendanceResponse checkIn(UUID sessionId, AttendanceRequest request, UUI
}


// 세션의 활성 라운드를 찾음
LocalDateTime now = LocalDateTime.now();
AttendanceRound activeRound = session.getRounds().stream()
.filter(round -> {
LocalDateTime roundStart = LocalDateTime.of(round.getRoundDate(), round.getStartTime());
LocalDateTime roundEnd = roundStart.plusMinutes(round.getAllowedMinutes());
return !now.isBefore(roundStart) && now.isBefore(roundEnd);
})
.findFirst()
.orElseThrow(() -> new IllegalStateException("현재 진행 중인 라운드가 없습니다"));
if (now.isBefore(session.getStartsAt())) {
throw new IllegalStateException("아직 출석 시간이 아닙니다");
}

LocalDateTime endTime = session.getEndsAt();
if (now.isAfter(endTime)) {
throw new IllegalStateException("출석 시간이 종료되었습니다");
}

// 시작 후 5분 이내는 정상 출석, 이후는 지각
LocalDateTime roundStart = LocalDateTime.of(activeRound.getRoundDate(), activeRound.getStartTime());
LocalDateTime lateThreshold = roundStart.plusMinutes(5);
LocalDateTime lateThreshold = session.getStartsAt().plusMinutes(5);
AttendanceStatus status = now.isAfter(lateThreshold) ?
AttendanceStatus.LATE : AttendanceStatus.PRESENT;

Attendance attendance = Attendance.builder()
.user(user)
.attendanceSession(session)
.attendanceRound(activeRound)
.attendanceStatus(status)
.checkedAt(now)
.awardedPoints(session.getRewardPoints())
Expand All @@ -104,7 +101,6 @@ public AttendanceResponse checkIn(UUID sessionId, AttendanceRequest request, UUI
.deviceInfo(request.getDeviceInfo())
.build();


attendance = attendanceRepository.save(attendance);

log.info("출석 체크인 완료: 사용자={}, 상태={}", user.getName(), status);
Expand Down
Loading