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 1799d770..50ecfb8c 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 @@ -2,14 +2,11 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.attendance.dto.AttendanceRequest; import org.sejongisc.backend.attendance.dto.AttendanceResponse; import org.sejongisc.backend.attendance.service.AttendanceService; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; 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 index 58efd94c..a1fc034a 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java @@ -7,10 +7,7 @@ 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.dto.*; import org.sejongisc.backend.attendance.service.AttendanceRoundService; import org.sejongisc.backend.attendance.service.AttendanceService; import org.sejongisc.backend.common.auth.jwt.JwtProvider; @@ -251,4 +248,29 @@ public ResponseEntity getAttendancesByRound( }}); } } + + /** + * 라운드의 출석 상태 수정 (관리자용) + * PUT /api/attendance/rounds/{roundId}/attendances/{userId} + */ + @Operation( + summary = "출석 상태 수정", + description = "특정 라운드의 사용자 출석 상태를 수정합니다. (관리자 전용) " + + "출석(PRESENT), 지각(LATE), 결석(ABSENT), 공결(EXCUSED) 중 하나의 상태로 변경할 수 있습니다." + ) + @PutMapping("/rounds/{roundId}/attendances/{userId}") + @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") + public ResponseEntity updateAttendanceStatus( + @PathVariable UUID roundId, + @PathVariable UUID userId, + @Valid @RequestBody AttendanceStatusUpdateRequest request) { + log.info("출석 상태 수정 요청: roundId={}, userId={}, 새로운상태={}, 사유={}", + roundId, userId, request.getStatus(), request.getReason()); + + AttendanceResponse response = attendanceService.updateAttendanceStatusByRound(roundId, userId, request.getStatus(), request.getReason()); + + log.info("출석 상태 수정 완료: roundId={}, userId={}", roundId, userId); + + return ResponseEntity.ok(response); + } } 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 fa45b245..2d3b49c7 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 @@ -5,11 +5,9 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; 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.dto.*; import org.sejongisc.backend.attendance.service.AttendanceSessionService; +import org.sejongisc.backend.attendance.service.SessionUserService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -29,6 +27,7 @@ public class AttendanceSessionController { private final AttendanceSessionService attendanceSessionService; + private final SessionUserService sessionUserService; /** * 출석 세션 생성 (관리자용) @@ -246,4 +245,74 @@ public ResponseEntity deleteSession(@PathVariable UUID sessionId) { return ResponseEntity.noContent().build(); } + + /** + * 세션에 사용자 추가 (관리자용) + * - 사용자를 세션에 추가 + * - 중복 참여 방지 + * - 자동으로 이전 라운드들에 결석 처리 + */ + @Operation( + summary = "세션에 사용자 추가", + description = "사용자를 출석 세션에 추가합니다. (관리자 전용) " + + "이미 참여 중인 사용자는 추가할 수 없으며, 추가 시 이전 라운드들은 자동으로 결석 처리됩니다." + ) + @PostMapping("/{sessionId}/users") + @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") + public ResponseEntity addUserToSession( + @PathVariable UUID sessionId, + @Valid @RequestBody SessionUserRequest request) { + log.info("세션에 사용자 추가: 세션ID={}, 사용자ID={}", sessionId, request.getUserId()); + + SessionUserResponse response = sessionUserService.addUserToSession(sessionId, request.getUserId()); + + log.info("세션에 사용자 추가 완료: 세션ID={}, 사용자명={}", sessionId, response.getUserName()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 세션에서 사용자 제거 (관리자용) + * - 사용자를 세션에서 제거 + * - 해당 사용자의 모든 출석 기록도 함께 삭제 + */ + @Operation( + summary = "세션에서 사용자 제거", + description = "사용자를 출석 세션에서 제거합니다. (관리자 전용) " + + "⚠️ 주의: 해당 사용자의 해당 세션에서의 모든 출석 기록이 함께 삭제됩니다." + ) + @DeleteMapping("/{sessionId}/users/{userId}") + @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") + public ResponseEntity removeUserFromSession( + @PathVariable UUID sessionId, + @PathVariable UUID userId) { + log.info("세션에서 사용자 제거: 세션ID={}, 사용자ID={}", sessionId, userId); + + sessionUserService.removeUserFromSession(sessionId, userId); + + log.info("세션에서 사용자 제거 완료: 세션ID={}, 사용자ID={}", sessionId, userId); + + return ResponseEntity.noContent().build(); + } + + /** + * 세션 참여자 조회 + * - 세션에 참여 중인 모든 사용자 목록 + * - 참여 순서대로 정렬 + */ + @Operation( + summary = "세션 참여자 조회", + description = "세션에 참여 중인 모든 사용자 목록을 조회합니다. " + + "각 사용자의 ID, 이름, 참여 시간 등의 정보가 포함됩니다." + ) + @GetMapping("/{sessionId}/users") + public ResponseEntity> getSessionUsers(@PathVariable UUID sessionId) { + log.info("세션 참여자 조회: 세션ID={}", sessionId); + + List users = sessionUserService.getSessionUsers(sessionId); + + log.info("세션 참여자 조회 완료: 세션ID={}, 참여자 수={}", sessionId, users.size()); + + return ResponseEntity.ok(users); + } } 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 b0ae0b9f..417ea025 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 @@ -64,5 +64,8 @@ public static class LocationInfo { @Schema(description = "경도", example = "127.0751") private Double lng; + + @Schema(description = "출석 인정 반경 (미터)", example = "100") + private Integer radiusMeters; } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceStatusUpdateRequest.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceStatusUpdateRequest.java new file mode 100644 index 00000000..d97f685b --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceStatusUpdateRequest.java @@ -0,0 +1,24 @@ +package org.sejongisc.backend.attendance.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 출석 상태 수정 요청 + * - POST /api/attendance/rounds/{roundId}/attendances/{userId} + * - body: {status, reason} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AttendanceStatusUpdateRequest { + + @NotBlank(message = "출석 상태는 필수입니다 (PRESENT, LATE, ABSENT, EXCUSED)") + private String status; + + private String reason; +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/RoundAttendanceResponse.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/RoundAttendanceResponse.java new file mode 100644 index 00000000..9394da12 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/RoundAttendanceResponse.java @@ -0,0 +1,31 @@ +package org.sejongisc.backend.attendance.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.sejongisc.backend.attendance.entity.AttendanceStatus; + +import java.util.UUID; + +/** + * 회차별 출석 인원 정보 + * - 요청: GET /api/attendance/rounds/{roundId}/attendances + * - 응답: [{userId, userName, status}, ...] + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoundAttendanceResponse { + + @JsonProperty("userId") + private UUID userId; + + @JsonProperty("userName") + private String userName; + + @JsonProperty("status") + private AttendanceStatus status; +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserRequest.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserRequest.java new file mode 100644 index 00000000..2eae2366 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserRequest.java @@ -0,0 +1,19 @@ +package org.sejongisc.backend.attendance.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SessionUserRequest { + + @NotNull(message = "사용자 ID는 필수입니다") + private UUID userId; +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserResponse.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserResponse.java new file mode 100644 index 00000000..73a2c666 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserResponse.java @@ -0,0 +1,26 @@ +package org.sejongisc.backend.attendance.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SessionUserResponse { + + private UUID sessionUserId; + + private UUID userId; + + private UUID sessionId; + + private String userName; + + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionUser.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionUser.java new file mode 100644 index 00000000..fe0c2dea --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionUser.java @@ -0,0 +1,68 @@ +package org.sejongisc.backend.attendance.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; +import org.sejongisc.backend.user.entity.User; + +import java.util.UUID; + +/** + * 세션에 참여하는 사용자를 관리하는 엔티티 + * + * 예: "금융동아리 2024년 정기 모임" 세션에 참여하는 팀원들 + * - 참여자 추가/삭제 관리 + * - 세션별 참여자 조회 + * - 중간 참여시 이전 라운드는 자동으로 결석 처리 + */ +@Entity +@Table( + name = "session_user", + uniqueConstraints = @UniqueConstraint( + columnNames = {"session_id", "user_id"}, + name = "uk_session_user" + ), + indexes = { + @Index(name = "idx_session_id", columnList = "session_id"), + @Index(name = "idx_user_id", columnList = "user_id") + } +) +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SessionUser extends BasePostgresEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "session_user_id", columnDefinition = "uuid") + private UUID sessionUserId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + private AttendanceSession attendanceSession; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "user_name", length = 100, nullable = false) + private String userName; // 저장 시점의 user.name 캐시 (나중에 user.name이 변경되어도 유지) + + /** + * toString 오버라이드 (순환 참조 방지) + */ + @Override + public String toString() { + return "SessionUser{" + + "sessionUserId=" + sessionUserId + + ", sessionId=" + (attendanceSession != null ? attendanceSession.getAttendanceSessionId() : null) + + ", userId=" + (user != null ? user.getUserId() : null) + + ", userName='" + userName + '\'' + + ", createdDate=" + getCreatedDate() + + '}'; + } +} 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 f44e2362..7cf20bc8 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 @@ -60,4 +60,8 @@ Long countByAttendanceSessionAndStatus(@Param("session") AttendanceSession sessi @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); + // 세션의 특정 사용자 모든 출석 기록 조회 (라운드 관계없이) + @Query("SELECT a FROM Attendance a WHERE a.attendanceSession = :session AND a.user.userId = :userId") + List findAllBySessionAndUserId(@Param("session") AttendanceSession session, @Param("userId") UUID userId); + } 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 index 778a62b3..dc67527d 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceRoundRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceRoundRepository.java @@ -45,4 +45,16 @@ public interface AttendanceRoundRepository extends JpaRepository findNthRoundInSession(@Param("sessionId") UUID sessionId, @Param("offset") int offset); + + /** + * 세션의 특정 날짜 이전의 모든 라운드 조회 + * - 세션에 유저 추가 시, 이전 라운드들에 자동으로 결석 처리하기 위해 사용 + */ + @Query("SELECT r FROM AttendanceRound r " + + "WHERE r.attendanceSession.attendanceSessionId = :sessionId " + + "AND r.roundDate < :date " + + "ORDER BY r.roundDate ASC") + List findBySession_SessionIdAndRoundDateBefore( + @Param("sessionId") UUID sessionId, + @Param("date") LocalDate date); } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/repository/SessionUserRepository.java b/backend/src/main/java/org/sejongisc/backend/attendance/repository/SessionUserRepository.java new file mode 100644 index 00000000..816ee724 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/repository/SessionUserRepository.java @@ -0,0 +1,61 @@ +package org.sejongisc.backend.attendance.repository; + +import org.sejongisc.backend.attendance.entity.SessionUser; +import org.sejongisc.backend.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface SessionUserRepository extends JpaRepository { + + /** + * 특정 세션의 모든 참여자 조회 + */ + @Query("SELECT su FROM SessionUser su WHERE su.attendanceSession.attendanceSessionId = :sessionId ORDER BY su.createdDate ASC") + List findBySessionId(@Param("sessionId") UUID sessionId); + + /** + * 특정 사용자가 참여하는 모든 세션 조회 + */ + @Query("SELECT su FROM SessionUser su WHERE su.user.userId = :userId ORDER BY su.createdDate DESC") + List findByUserId(@Param("userId") UUID userId); + + /** + * 세션과 사용자 조합으로 조회 + */ + @Query("SELECT su FROM SessionUser su WHERE su.attendanceSession.attendanceSessionId = :sessionId AND su.user.userId = :userId") + Optional findBySessionIdAndUserId(@Param("sessionId") UUID sessionId, @Param("userId") UUID userId); + + /** + * 세션에 특정 사용자가 참여하는지 확인 + */ + @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") + boolean existsBySessionIdAndUserId(@Param("sessionId") UUID sessionId, @Param("userId") UUID userId); + + /** + * 세션의 참여자 수 + */ + @Query("SELECT COUNT(su) FROM SessionUser su WHERE su.attendanceSession.attendanceSessionId = :sessionId") + long countBySessionId(@Param("sessionId") UUID sessionId); + + /** + * 세션의 모든 참여자 삭제 + */ + @Modifying + @Query("DELETE FROM SessionUser su WHERE su.attendanceSession.attendanceSessionId = :sessionId") + void deleteBySessionId(@Param("sessionId") UUID sessionId); + + /** + * 세션에서 특정 사용자 삭제 + */ + @Modifying + @Query("DELETE FROM SessionUser su WHERE su.attendanceSession.attendanceSessionId = :sessionId AND su.user.userId = :userId") + void deleteBySessionIdAndUserId(@Param("sessionId") UUID sessionId, @Param("userId") UUID userId); +} 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 d9d2067c..a0bbf4df 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 @@ -4,7 +4,6 @@ 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.*; import org.sejongisc.backend.attendance.repository.AttendanceRepository; @@ -15,7 +14,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -290,6 +288,66 @@ public List getAttendancesByRound(java.util.UUID roundId) { .collect(Collectors.toList()); } + /** + * 라운드 기반 출석 상태 수정 (관리자용) + * - roundId, userId, status를 받아 해당 라운드의 출석 상태 변경 + * - 라운드가 없으면 새로 생성 (예: 결석 처리) + */ + public AttendanceResponse updateAttendanceStatusByRound(UUID roundId, UUID userId, String status, String reason) { + log.info("📝 라운드 기반 출석 상태 수정 시작: roundId={}, userId={}, status={}", roundId, userId, status); + + // 1. 라운드 존재 확인 + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + roundId)); + + // 2. 사용자 존재 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); + + // 3. 상태 값 검증 + AttendanceStatus newStatus; + try { + newStatus = AttendanceStatus.valueOf(status.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("잘못된 출석 상태입니다: " + status); + } + + log.info("✅ 유효성 검사 완료: roundId={}, userId={}, newStatus={}", roundId, userId, newStatus); + + // 4. 기존 출석 기록 조회 + Attendance attendance = attendanceRepository.findByAttendanceRound_RoundIdAndUser(roundId, user) + .orElse(null); + + if (attendance == null) { + // 기존 기록이 없으면 새로 생성 (예: 결석 처리) + log.info("📌 새로운 Attendance 레코드 생성: 기존 기록 없음"); + + attendance = Attendance.builder() + .user(user) + .attendanceSession(round.getAttendanceSession()) + .attendanceRound(round) + .attendanceStatus(newStatus) + .note(reason != null ? reason : "관리자가 추가함") + .checkedAt(java.time.LocalDateTime.now()) + .build(); + + attendance = attendanceRepository.save(attendance); + log.info("💾 새 Attendance 레코드 저장 완료: attendanceId={}", attendance.getAttendanceId()); + } else { + // 기존 기록이 있으면 상태 업데이트 + log.info("📝 기존 Attendance 레코드 업데이트"); + + attendance.updateStatus(newStatus, reason); + attendance = attendanceRepository.save(attendance); + log.info("✅ Attendance 상태 업데이트 완료: status={}", newStatus); + } + + log.info("✅ 라운드 기반 출석 상태 수정 완료: roundId={}, userId={}, status={}", + roundId, userId, newStatus); + + return convertToResponse(attendance); + } + /** * Attendance 엔티티를 AttendanceResponse DTO로 변환 * - 엔티티의 모든 필드를 Response 형태로 매핑 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 4dd960f3..8a5f920a 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 @@ -13,8 +13,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -38,7 +36,6 @@ public AttendanceSessionResponse createSession(AttendanceSessionRequest request) log.info("출석 세션 생성 시작: 제목={}, 기본시간={}, 출석인정시간={}분", request.getTitle(), request.getDefaultStartTime(), request.getAllowedMinutes()); - String code = generateUniqueCode(); Location location = null; @@ -293,7 +290,6 @@ private AttendanceSessionResponse convertToResponse(AttendanceSession session) { .defaultAvailableMinutes(session.getAllowedMinutes()) .rewardPoints(session.getRewardPoints()) .build(); - } } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java new file mode 100644 index 00000000..b345b232 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java @@ -0,0 +1,192 @@ +package org.sejongisc.backend.attendance.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.attendance.dto.SessionUserResponse; +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.attendance.repository.SessionUserRepository; +import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.entity.User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class SessionUserService { + + private final SessionUserRepository sessionUserRepository; + private final AttendanceSessionRepository attendanceSessionRepository; + private final AttendanceRoundRepository attendanceRoundRepository; + private final AttendanceRepository attendanceRepository; + private final UserRepository userRepository; + + /** + * 세션에 사용자 추가 + * - 사용자가 이미 참여 중이면 예외 발생 + * - 세션의 이전 라운드들에 대해 자동으로 ABSENT 상태의 Attendance 레코드 생성 + * + * 흐름: + * 1. 세션과 사용자 존재 확인 + * 2. 중복 참여 여부 확인 + * 3. SessionUser 레코드 생성 + * 4. 이전 라운드들에 대해 결석 처리 + */ + public SessionUserResponse addUserToSession(UUID sessionId, UUID userId) { + log.info("🔧 세션에 사용자 추가 시작: sessionId={}, userId={}", sessionId, userId); + + // 1. 세션 존재 확인 + AttendanceSession session = attendanceSessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다: " + sessionId)); + + // 2. 사용자 존재 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); + + // 3. 중복 참여 여부 확인 + if (sessionUserRepository.existsBySessionIdAndUserId(sessionId, userId)) { + throw new IllegalArgumentException("이미 세션에 참여 중입니다: " + user.getName()); + } + + log.info("✅ 유효성 검사 완료: sessionId={}, userId={}, userName={}", sessionId, userId, user.getName()); + + // 4. SessionUser 레코드 생성 + SessionUser sessionUser = SessionUser.builder() + .attendanceSession(session) + .user(user) + .userName(user.getName()) + .build(); + + sessionUser = sessionUserRepository.save(sessionUser); + log.info("💾 SessionUser 저장 완료: sessionUserId={}, userName={}", sessionUser.getSessionUserId(), user.getName()); + + // 5. ⭐ 핵심: 이미 진행된 라운드들에 대해 자동으로 결석 처리 + List pastRounds = attendanceRoundRepository.findBySession_SessionIdAndRoundDateBefore( + sessionId, + LocalDate.now() + ); + + if (!pastRounds.isEmpty()) { + log.info("📅 과거 라운드 자동 결석 처리: 이전 라운드 수={}", pastRounds.size()); + + for (AttendanceRound round : pastRounds) { + // 이미 해당 라운드에 출석 기록이 있는지 확인 + boolean alreadyExists = attendanceRepository.findByAttendanceRound_RoundIdAndUser(round.getRoundId(), user) + .isPresent(); + + if (!alreadyExists) { + // 새로운 Attendance 레코드 생성 (결석 상태) + Attendance absentRecord = Attendance.builder() + .user(user) + .attendanceSession(session) + .attendanceRound(round) + .attendanceStatus(AttendanceStatus.ABSENT) + .note("세션 중간 참여 - 이전 라운드는 자동 결석 처리") + .checkedAt(java.time.LocalDateTime.now()) + .build(); + + attendanceRepository.save(absentRecord); + log.info(" - 결석 기록 생성: roundId={}, date={}, userName={}", + round.getRoundId(), round.getRoundDate(), user.getName()); + } + } + + log.info("✅ 과거 라운드 자동 결석 처리 완료: 처리된 라운드 수={}", pastRounds.size()); + } + + log.info("✅ 세션에 사용자 추가 완료: sessionId={}, userId={}, userName={}", + sessionId, userId, user.getName()); + + return convertToResponse(sessionUser); + } + + /** + * 세션에서 사용자 제거 + * - SessionUser 레코드 삭제 + * - 해당 사용자의 모든 Attendance 레코드도 함께 삭제 (관련된 모든 라운드의 출석 기록 제거) + */ + public void removeUserFromSession(UUID sessionId, UUID userId) { + log.info("🗑️ 세션에서 사용자 제거 시작: sessionId={}, userId={}", sessionId, userId); + + // 1. 세션 존재 확인 + AttendanceSession session = attendanceSessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다: " + sessionId)); + + // 2. 사용자 존재 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); + + // 3. SessionUser 레코드 삭제 + sessionUserRepository.deleteBySessionIdAndUserId(sessionId, userId); + log.info("💾 SessionUser 레코드 삭제 완료: userName={}", user.getName()); + + // 4. ⭐ 해당 세션의 모든 Attendance 레코드 삭제 (해당 라운드별 출석 기록 모두 제거) + List attendancesToDelete = attendanceRepository.findAllBySessionAndUserId(session, userId); + + if (!attendancesToDelete.isEmpty()) { + log.info("🗑️ Attendance 레코드 삭제 시작: 삭제 대상 수={}", attendancesToDelete.size()); + + attendanceRepository.deleteAll(attendancesToDelete); + + log.info("✅ Attendance 레코드 삭제 완료: 삭제된 레코드 수={}", attendancesToDelete.size()); + for (Attendance a : attendancesToDelete) { + log.info(" - 삭제됨: roundId={}, status={}", + a.getAttendanceRound() != null ? a.getAttendanceRound().getRoundId() : "null", + a.getAttendanceStatus()); + } + } + + log.info("✅ 세션에서 사용자 제거 완료: sessionId={}, userId={}, userName={}", + sessionId, userId, user.getName()); + } + + /** + * 세션의 모든 참여자 조회 + */ + @Transactional(readOnly = true) + public List getSessionUsers(UUID sessionId) { + log.info("📋 세션 참여자 조회: sessionId={}", sessionId); + + AttendanceSession session = attendanceSessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다: " + sessionId)); + + List sessionUsers = sessionUserRepository.findBySessionId(sessionId); + + log.info("📊 세션 참여자 조회 결과: sessionId={}, 참여자 수={}", + sessionId, sessionUsers.size()); + + return sessionUsers.stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + /** + * 특정 사용자가 세션에 참여하는지 확인 + */ + @Transactional(readOnly = true) + public boolean isUserInSession(UUID sessionId, UUID userId) { + return sessionUserRepository.existsBySessionIdAndUserId(sessionId, userId); + } + + /** + * SessionUser를 SessionUserResponse로 변환 + */ + private SessionUserResponse convertToResponse(SessionUser sessionUser) { + return SessionUserResponse.builder() + .sessionUserId(sessionUser.getSessionUserId()) + .userId(sessionUser.getUser().getUserId()) + .sessionId(sessionUser.getAttendanceSession().getAttendanceSessionId()) + .userName(sessionUser.getUserName()) + .createdAt(sessionUser.getCreatedDate()) + .build(); + } +}