From 4efe9ef8a11f0600f5000283e3d812d7c84273b8 Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Sat, 17 Jan 2026 15:41:18 +0900 Subject: [PATCH 01/14] =?UTF-8?q?fix(entity):=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EA=B4=80=EB=A0=A8=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/AttendanceCheckInRequest.java | 5 - .../attendance/dto/AttendanceRequest.java | 8 - .../backend/attendance/entity/Attendance.java | 83 +---- .../attendance/entity/AttendanceRound.java | 88 ++--- .../attendance/entity/AttendanceSession.java | 35 +- .../attendance/entity/SessionRole.java | 13 + .../attendance/entity/SessionStatus.java | 11 +- .../attendance/entity/SessionUser.java | 13 +- .../service/AttendanceRoundService.java | 17 +- .../attendance/service/AttendanceService.java | 10 +- .../service/AttendanceSessionService.java | 20 +- .../service/SessionUserService.java | 14 +- .../controller/AttendanceControllerTest.java | 54 --- .../service/AttendanceRoundCheckInTest.java | 334 ------------------ .../service/AttendanceRoundServiceTest.java | 263 -------------- .../service/AttendanceServiceTest.java | 311 ---------------- 16 files changed, 68 insertions(+), 1211 deletions(-) create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionRole.java 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 index 8d5b875e..4a04a20c 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInRequest.java @@ -25,11 +25,6 @@ ) public class AttendanceCheckInRequest { - @NotNull(message = "라운드 ID는 필수입니다") - @Schema( - description = "체크인할 라운드의 고유 ID", - example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - ) private UUID roundId; @DecimalMin(value = "-90.0", message = "위도는 -90도 이상이어야 합니다") diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRequest.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRequest.java index d32463ad..83648720 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRequest.java @@ -59,12 +59,4 @@ public class AttendanceRequest { @Size(max = 500, message = "메모는 500자 이하여야 합니다") private String note; - @Schema( - description = "체크인에 사용한 디바이스 정보. 예: 'iPhone 12', 'Android Pixel 6' 등", - example = "iPhone 14 Pro", - maxLength = 200 - ) - @Size(max = 200, message = "디바이스 정보는 200자 이하여야 합니다") - private String deviceInfo; - } 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 5219b874..9c138d7e 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 @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonBackReference; import jakarta.persistence.*; +import java.time.LocalTime; import lombok.*; import org.hibernate.annotations.CreationTimestamp; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; @@ -15,6 +16,13 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Table( + name = "attendance", + uniqueConstraints = @UniqueConstraint( + name = "uk_attendance_round_user", + columnNames = {"round_id", "user_id"} + ) +) public class Attendance extends BasePostgresEntity { @Id @@ -27,13 +35,7 @@ public class Attendance extends BasePostgresEntity { private User user; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "session_id", nullable = false) - @JsonBackReference - private AttendanceSession attendanceSession; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "round_id", nullable = true) - @JsonBackReference + @JoinColumn(name = "round_id", nullable = false) private AttendanceRound attendanceRound; @Enumerated(EnumType.STRING) @@ -42,13 +44,16 @@ public class Attendance extends BasePostgresEntity { @CreationTimestamp @Column(name = "checked_at") - @lombok.Setter private LocalDateTime checkedAt; + // todo User의 point와 동기화 필요 + // 출석했을때 무조건 100포인트라면 굳이 필요 없을 듯 + // 이거 session에 정해져 있지 않나 여기에 없어도 될거 같은데 @Column(name = "awarded_points") @lombok.Setter private Integer awardedPoints; + // todo 지각 사유나 특이사항 적는칸-> 개인이 작성하면 관리자만 볼 수 있게 해야할거 같은디 @Column(length = 500) @lombok.Setter private String note; @@ -57,68 +62,10 @@ public class Attendance extends BasePostgresEntity { @lombok.Setter private Location checkInLocation; - @Column(name = "device_info") - @lombok.Setter - private String deviceInfo; - - @Column(name = "anonymous_user_name", length = 100) - @lombok.Setter - private String anonymousUserName; // 지각 여부 계산 / 상태 업데이트 - /** - * 지각 여부 판단 - * - 라운드 기반: attendanceRound의 startTime 기준 - * - 세션 기반: attendanceSession의 defaultStartTime 기준 (5분) - */ - public boolean isLate() { - if (checkedAt == 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); - } - - /** - * 상태 업데이트 (관리자용) - */ - public void updateStatus(AttendanceStatus newStatus, String reason) { - this.attendanceStatus = newStatus; - if (reason != null && !reason.trim().isEmpty()) { - this.note = reason; - } - } - - /** - * 출석 시간 자동 설정 - */ - public void markPresent() { - this.attendanceStatus = AttendanceStatus.PRESENT; - this.checkedAt = LocalDateTime.now(); - } - - /** - * 지각 처리 - */ - public void markLate() { - this.attendanceStatus = AttendanceStatus.LATE; - this.checkedAt = LocalDateTime.now(); - } + + } 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 index 6e09f17f..49525a99 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceRound.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceRound.java @@ -1,8 +1,11 @@ package org.sejongisc.backend.attendance.entity; +import static java.time.Duration.ofMinutes; + import com.fasterxml.jackson.annotation.JsonBackReference; import com.fasterxml.jackson.annotation.JsonManagedReference; import jakarta.persistence.*; +import java.time.Duration; import lombok.*; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; @@ -14,10 +17,6 @@ /** * 출석 세션 내 개별 라운드(주차) - * - * 예: "금융동아리 2024년 정기 모임" 세션 내 - * - 라운드 1: 2025-11-06, 10:00~11:00 - * - 라운드 2: 2025-11-13, 10:00~11:00 */ @Entity @Getter @@ -41,82 +40,35 @@ public class AttendanceRound extends BasePostgresEntity { private LocalDate roundDate; // 라운드 날짜 (예: 2025-11-06) @Column(nullable = false) - private LocalTime startTime; // 출석 시작 시간 (예: 10:00) + private LocalTime startTime; // 시작 시간 (예: 10:00) @Column(nullable = false) - private Integer allowedMinutes; // 출석 인정 시간 (분단위, 예: 30) + private LocalTime endTime; // 종료 시간 (예: 10:20) @Enumerated(EnumType.STRING) @Column(nullable = false) + + // todo 라운드 상태 관리 로직 필요 + // 생성시 upcoming, 출석 시작시 active, 출석 종료시 closed private RoundStatus roundStatus; // UPCOMING, ACTIVE, CLOSED + @Column(name = "round_name", length = 255, nullable = true) private String roundName; // 라운드 이름 (예: "1차 정기모임", "OT" 등) + @Column(nullable = false) + private String locationName; // 장소 이름 (예: "세종대학교 310동") + + // todo 라운드별 관리자에게만 발급되는 큐알 코드는 필요할 거 같음 + private String qrCode; // 라운드별 출석 QR 코드 문자열 + + // 라운드별 참석 조회용 @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 f5dc0801..10a05aca 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 @@ -23,18 +23,17 @@ public class AttendanceSession extends BasePostgresEntity { @Column(name = "attendance_session_id", columnDefinition = "uuid") private UUID attendanceSessionId; + + @Column(nullable = false) private String title; // "세투연 9/17" - @Column(name = "default_start_time", nullable = false) - private LocalTime defaultStartTime; // 세션 기본 시작 시간 (예: 18:30:00) @Column(name = "allowed_minutes", nullable = false) private Integer allowedMinutes; // 출석 인정 시간(분) - 예: 30분 - @Column(unique = true, length = 6) - private String code; // 6자리 출석 코드 "942715" + // todo 세션별 포인트 다르게 줄거면 필요 아니면 굳이?, User의 point와 동기화 필요 @Column(name = "reward_points") private Integer rewardPoints; // 출석 시 지급할 포인트 @@ -44,39 +43,13 @@ 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, orphanRemoval = true) - @JsonManagedReference @Builder.Default private List sessionUsers = new ArrayList<>(); - @OneToMany(mappedBy = "attendanceSession", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) - @JsonManagedReference - @Builder.Default - private List attendances = new ArrayList<>(); - - /** - * 세션 종료 시간 계산 (시간만) - */ - public LocalTime getEndTime() { - return defaultStartTime.plusMinutes(allowedMinutes != null ? allowedMinutes : 30); - } - - /** - * 특정 라운드 날짜에서 세션이 진행 중인지 확인 - */ - public boolean isCheckInAvailableForRound(java.time.LocalTime currentTime) { - return !currentTime.isBefore(defaultStartTime) && currentTime.isBefore(getEndTime()); - } - - /** - * 현재 세션 상태 계산 (라운드별) - */ - public SessionStatus calculateCurrentStatus() { - return SessionStatus.OPEN; - } } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionRole.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionRole.java new file mode 100644 index 00000000..093d22c5 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionRole.java @@ -0,0 +1,13 @@ +package org.sejongisc.backend.attendance.entity; + +import lombok.RequiredArgsConstructor; + +/** + * 세션 역할 + */ +@RequiredArgsConstructor +public enum SessionRole { + MANAGER("관리자"), // 세션 관리자 + PARTICIPANT("참가자"); // 세션 참가자 + private final String description; +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionStatus.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionStatus.java index 6460e9cf..46924842 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionStatus.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionStatus.java @@ -1,17 +1,12 @@ package org.sejongisc.backend.attendance.entity; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor public enum SessionStatus { UPCOMING("예정"), // 아직 시작 전 OPEN("진행중"), // 체크인 가능한 상태 CLOSED("종료"); // 체크인 시간 마감 private final String description; - - SessionStatus(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } } 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 index dc646e0b..4207b8a9 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionUser.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionUser.java @@ -11,10 +11,6 @@ /** * 세션에 참여하는 사용자를 관리하는 엔티티 * - * 예: "금융동아리 2024년 정기 모임" 세션에 참여하는 팀원들 - * - 참여자 추가/삭제 관리 - * - 세션별 참여자 조회 - * - 중간 참여시 이전 라운드는 자동으로 결석 처리 */ @Entity @Table( @@ -41,16 +37,16 @@ public class SessionUser extends BasePostgresEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "session_id", nullable = false) - @JsonBackReference private AttendanceSession attendanceSession; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; - @Column(name = "user_name", length = 100, nullable = false) - @lombok.Setter - private String userName; // 저장 시점의 user.name 캐시 (나중에 user.name이 변경되어도 유지) + // 세션 내 사용자 역할 + @Enumerated(EnumType.STRING) + private SessionRole sessionRole; + /** * toString 오버라이드 (순환 참조 방지) @@ -61,7 +57,6 @@ public String toString() { "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/service/AttendanceRoundService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java index 70265475..63d19556 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java @@ -58,19 +58,10 @@ public AttendanceRoundResponse createRound(UUID sessionId, AttendanceRoundReques 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); @@ -85,7 +76,6 @@ public AttendanceRoundResponse createRound(UUID sessionId, AttendanceRoundReques for (SessionUser sessionUser : sessionUsers) { Attendance pendingAttendance = Attendance.builder() .user(sessionUser.getUser()) - .attendanceSession(session) .attendanceRound(saved) .attendanceStatus(AttendanceStatus.PENDING) .build(); @@ -133,12 +123,7 @@ public List getRoundsBySession(UUID sessionId) { 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); 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 73449e60..d42d02f9 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 @@ -50,7 +50,7 @@ public AttendanceCheckInResponse checkInByRound(AttendanceCheckInRequest request log.info("라운드 출석 체크인 시작: 사용자={}, 라운드ID={}, 날짜={}", user.getName(), request.getRoundId(), round.getRoundDate()); - // 1. 라운드 시간 검증 - 통일된 로직 + // 라운드 시간 검증 - 통일된 로직 LocalDateTime now = LocalDateTime.now(); LocalDate checkDate = now.toLocalDate(); LocalTime checkTime = now.toLocalTime(); @@ -136,7 +136,6 @@ public AttendanceCheckInResponse checkInByRound(AttendanceCheckInRequest request // 5. 출석 기록 저장 Attendance attendance = Attendance.builder() .user(user) - .attendanceSession(session) .attendanceRound(round) .attendanceStatus(status) .checkedAt(java.time.LocalDateTime.now()) @@ -229,7 +228,6 @@ public AttendanceResponse updateAttendanceStatus(UUID sessionId, UUID memberId, throw new IllegalArgumentException("잘못된 출석 상태입니다: " + status); } - attendance.updateStatus(newStatus, reason); attendance = attendanceRepository.save(attendance); log.info("출석 상태 수정 완료: 세션ID={}, 멤버ID={}, 상태={}", sessionId, memberId, newStatus); @@ -303,7 +301,6 @@ public AttendanceResponse updateAttendanceStatusByRound(UUID roundId, UUID userI attendance = Attendance.builder() .user(user) - .attendanceSession(round.getAttendanceSession()) .attendanceRound(round) .attendanceStatus(newStatus) .note(reason != null ? reason : "관리자가 추가함") @@ -316,7 +313,7 @@ public AttendanceResponse updateAttendanceStatusByRound(UUID roundId, UUID userI // 기존 기록이 있으면 상태 업데이트 log.info("📝 기존 Attendance 레코드 업데이트"); - attendance.updateStatus(newStatus, reason); + attendance = attendanceRepository.save(attendance); log.info("✅ Attendance 상태 업데이트 완료: status={}", newStatus); } @@ -337,7 +334,6 @@ private AttendanceResponse convertToResponse(Attendance attendance) { .attendanceId(attendance.getAttendanceId()) .userId(attendance.getUser() != null ? attendance.getUser().getUserId() : null) .userName(attendance.getUser() != null ? attendance.getUser().getName() : "익명") - .attendanceSessionId(attendance.getAttendanceSession().getAttendanceSessionId()) .attendanceRoundId(attendance.getAttendanceRound() != null ? attendance.getAttendanceRound().getRoundId() : null) .attendanceStatus(attendance.getAttendanceStatus()) @@ -348,8 +344,6 @@ private AttendanceResponse convertToResponse(Attendance attendance) { attendance.getCheckInLocation().getLat() : null) .checkInLongitude(attendance.getCheckInLocation() != null ? attendance.getCheckInLocation().getLng() : null) - .deviceInfo(attendance.getDeviceInfo()) - .isLate(attendance.isLate()) .createdAt(attendance.getCreatedDate()) .updatedAt(attendance.getUpdatedDate()) .build(); 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 4e54198a..c3caa522 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 @@ -28,15 +28,12 @@ public class AttendanceSessionService { /** * 출석 세션 생성 - * - 6자리 유니크 코드 자동 생성 * - GPS 위치 및 반경 설정 (선택사항) * - 기본 상태 UPCOMING 으로 설정 */ public AttendanceSessionResponse createSession(AttendanceSessionRequest request) { log.info("출석 세션 생성 시작: 제목={}, 기본시간={}, 출석인정시간={}분", request.getTitle(), request.getDefaultStartTime(), request.getAllowedMinutes()); - - String code = generateUniqueCode(); Location location = null; if (request.getLatitude() != null && request.getLongitude() != null) { @@ -49,9 +46,7 @@ public AttendanceSessionResponse createSession(AttendanceSessionRequest request) AttendanceSession session = AttendanceSession.builder() .title(request.getTitle()) - .defaultStartTime(request.getDefaultStartTime()) .allowedMinutes(request.getAllowedMinutes()) - .code(code) .rewardPoints(request.getRewardPoints()) .location(location) .status(SessionStatus.UPCOMING) @@ -59,7 +54,7 @@ public AttendanceSessionResponse createSession(AttendanceSessionRequest request) session = attendanceSessionRepository.save(session); - log.info("출석 세션 생성 완료: 세션ID={}, 코드={}", session.getAttendanceSessionId(), code); + log.info("출석 세션 생성 완료: 세션ID={}", session.getAttendanceSessionId()); return convertToResponse(session); } @@ -143,7 +138,6 @@ public AttendanceSessionResponse updateSession(UUID sessionId, AttendanceSession session = session.toBuilder() .title(request.getTitle()) - .defaultStartTime(request.getDefaultStartTime()) .allowedMinutes(request.getAllowedMinutes()) .rewardPoints(request.getRewardPoints()) .location(location) @@ -212,17 +206,6 @@ public void closeSession(UUID sessionId) { log.info("출석 세션 종료 완료: 세션ID={}", sessionId); } - /** - * 중복되지 않는 6자리 코드 생성 - * - DB에서 중복 검사 후 유니크 코드 리턴 - */ - private String generateUniqueCode() { - String code; - do { - code = generateRandomCode(); - } while (attendanceSessionRepository.existsByCode(code)); - return code; - } /** * 세션 위치 재설정 @@ -287,7 +270,6 @@ private AttendanceSessionResponse convertToResponse(AttendanceSession session) { .attendanceSessionId(session.getAttendanceSessionId()) .title(session.getTitle()) .location(location) - .defaultStartTime(session.getDefaultStartTime()) .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 index 907a6b19..9e843dd4 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java @@ -44,32 +44,31 @@ public class SessionUserService { 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 = 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() @@ -87,7 +86,6 @@ public SessionUserResponse addUserToSession(UUID sessionId, UUID userId) { // 새로운 Attendance 레코드 생성 (결석 상태) Attendance absentRecord = Attendance.builder() .user(user) - .attendanceSession(session) .attendanceRound(round) .attendanceStatus(AttendanceStatus.ABSENT) .note("세션 중간 참여 - 이전 라운드는 자동 결석 처리") @@ -121,7 +119,6 @@ public SessionUserResponse addUserToSession(UUID sessionId, UUID userId) { // 새로운 Attendance 레코드 생성 (PENDING 상태) Attendance pendingRecord = Attendance.builder() .user(user) - .attendanceSession(session) .attendanceRound(round) .attendanceStatus(AttendanceStatus.PENDING) .build(); @@ -217,7 +214,6 @@ private SessionUserResponse convertToResponse(SessionUser sessionUser) { .sessionUserId(sessionUser.getSessionUserId()) .userId(sessionUser.getUser().getUserId()) .sessionId(sessionUser.getAttendanceSession().getAttendanceSessionId()) - .userName(sessionUser.getUserName()) .createdAt(sessionUser.getCreatedDate()) .build(); } diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java index 978dce3c..cc7a0925 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java @@ -50,61 +50,7 @@ public class AttendanceControllerTest { @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; - @Test - @DisplayName("출석 체크인 성공") - @WithMockUser - void checkIn_success() throws Exception { - //given - AttendanceRequest request = AttendanceRequest.builder() - .code("123456") - .latitude(37.5665) - .longitude(126.9780) - .note("정상 춣석") - .deviceInfo("iphone 14") - .build(); - - User user = User.builder() - .userId(UUID.randomUUID()) - .name("오찬혁") - .email("oh@example.com") - .role(Role.TEAM_MEMBER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(user); - - AttendanceResponse response = AttendanceResponse.builder() - .attendanceId(UUID.randomUUID()) - .userId(user.getUserId()) - .userName("오찬혁") - .attendanceSessionId(UUID.randomUUID()) - .attendanceStatus(AttendanceStatus.PRESENT) - .checkedAt(LocalDateTime.now()) - .awardedPoints(10) - .note("정상 출석") - .deviceInfo("iphone 14") - .isLate(false) - .build(); - when(attendanceService.checkIn(any(UUID.class), any(AttendanceRequest.class), eq(user.getUserId()))).thenReturn(response); - - //then - UUID sessionId = UUID.randomUUID(); - mockMvc.perform(post("/api/attendance/sessions/{sessionId}/check-in", sessionId) - .with(user(userDetails)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.userName").value("오찬혁")) - .andExpect(jsonPath("$.attendanceStatus").value("PRESENT")) - .andExpect(jsonPath("$.awardedPoints").value(10)) - .andExpect(jsonPath("$.note").value("정상 출석")) - .andExpect(jsonPath("$.deviceInfo").value("iphone 14")) - .andExpect(jsonPath("$.late").value(false)); - } - - @Test - @DisplayName("출석 체크인 실패: 유효성 검증 오류") - @WithMockUser void checkIn_fail_validation() throws Exception { //given AttendanceRequest request = AttendanceRequest.builder() diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java index 946d5f70..9dbc0f6d 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java @@ -45,339 +45,5 @@ public class AttendanceRoundCheckInTest { @InjectMocks private AttendanceService attendanceService; - @Test - @DisplayName("라운드 기반 출석 체크인 성공 - 정상 출석") - void checkInByRound_success_present() { - // given - UUID userId = UUID.randomUUID(); - UUID roundId = UUID.randomUUID(); - UUID sessionId = UUID.randomUUID(); - User user = User.builder() - .userId(userId) - .name("테스트 사용자") - .build(); - - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("테스트 세션") - .code("123456") - .rewardPoints(10) - .build(); - - LocalTime now = LocalTime.now(); - AttendanceRound round = AttendanceRound.builder() - .roundId(roundId) - .attendanceSession(session) - .roundDate(LocalDate.now()) - .startTime(now.minusMinutes(2)) - .allowedMinutes(30) - .roundStatus(RoundStatus.ACTIVE) - .build(); - - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .build(); - - Attendance savedAttendance = Attendance.builder() - .attendanceId(UUID.randomUUID()) - .user(user) - .attendanceSession(session) - .attendanceRound(round) - .attendanceStatus(AttendanceStatus.PRESENT) - .checkedAt(LocalDateTime.now()) - .awardedPoints(10) - .build(); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.of(round)); - when(attendanceRepository.findByAttendanceRound_RoundIdAndUser(roundId, user)).thenReturn(Optional.empty()); - when(attendanceRepository.save(any(Attendance.class))).thenReturn(savedAttendance); - - // when - AttendanceCheckInResponse response = attendanceService.checkInByRound(request, userId); - - // then - assertAll( - () -> assertThat(response.getSuccess()).isTrue(), - () -> assertThat(response.getStatus()).isEqualTo(AttendanceStatus.PRESENT.toString()), - () -> assertThat(response.getRoundId()).isEqualTo(roundId), - () -> assertThat(response.getAwardedPoints()).isEqualTo(10), - () -> assertThat(response.getFailureReason()).isNull() - ); - - verify(attendanceRepository).save(any(Attendance.class)); - } - - @Test - @DisplayName("라운드 기반 출석 체크인 성공 - 지각") - void checkInByRound_success_late() { - // given - UUID userId = UUID.randomUUID(); - UUID roundId = UUID.randomUUID(); - UUID sessionId = UUID.randomUUID(); - - User user = User.builder() - .userId(userId) - .name("테스트 사용자") - .build(); - - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("테스트 세션") - .code("123456") - .rewardPoints(10) - .build(); - - LocalTime now = LocalTime.now(); - AttendanceRound round = AttendanceRound.builder() - .roundId(roundId) - .attendanceSession(session) - .roundDate(LocalDate.now()) - .startTime(now.minusMinutes(10)) // 10분 전 시작 - .allowedMinutes(30) - .roundStatus(RoundStatus.ACTIVE) - .build(); - - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .build(); - - Attendance savedAttendance = Attendance.builder() - .attendanceId(UUID.randomUUID()) - .user(user) - .attendanceSession(session) - .attendanceRound(round) - .attendanceStatus(AttendanceStatus.LATE) - .checkedAt(LocalDateTime.now()) - .awardedPoints(10) - .build(); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.of(round)); - when(attendanceRepository.findByAttendanceRound_RoundIdAndUser(roundId, user)).thenReturn(Optional.empty()); - when(attendanceRepository.save(any(Attendance.class))).thenReturn(savedAttendance); - - // when - AttendanceCheckInResponse response = attendanceService.checkInByRound(request, userId); - - // then - assertAll( - () -> assertThat(response.getSuccess()).isTrue(), - () -> assertThat(response.getStatus()).isEqualTo(AttendanceStatus.LATE.toString()) - ); - } - - @Test - @DisplayName("라운드 기반 출석 체크인 실패 - 출석 시간 초과") - void checkInByRound_fail_timeout() { - // given - UUID userId = UUID.randomUUID(); - UUID roundId = UUID.randomUUID(); - - User user = User.builder() - .userId(userId) - .name("테스트 사용자") - .build(); - - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(UUID.randomUUID()) - .title("테스트 세션") - .code("123456") - .rewardPoints(10) - .build(); - - LocalTime now = LocalTime.now(); - AttendanceRound round = AttendanceRound.builder() - .roundId(roundId) - .attendanceSession(session) - .roundDate(LocalDate.now()) - .startTime(now.minusMinutes(35)) // 35분 전 시작 (30분 + 5분) - .allowedMinutes(30) - .roundStatus(RoundStatus.CLOSED) - .build(); - - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .build(); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.of(round)); - - // when - AttendanceCheckInResponse response = attendanceService.checkInByRound(request, userId); - - // then - assertAll( - () -> assertThat(response.getSuccess()).isFalse(), - () -> assertThat(response.getFailureReason()).isEqualTo("출석 시간 초과") - ); - } - - @Test - @DisplayName("라운드 기반 출석 체크인 실패 - 중복 출석") - void checkInByRound_fail_duplicate() { - // given - UUID userId = UUID.randomUUID(); - UUID roundId = UUID.randomUUID(); - - User user = User.builder() - .userId(userId) - .name("테스트 사용자") - .build(); - - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(UUID.randomUUID()) - .title("테스트 세션") - .code("123456") - .rewardPoints(10) - .build(); - - LocalTime now = LocalTime.now(); - AttendanceRound round = AttendanceRound.builder() - .roundId(roundId) - .attendanceSession(session) - .roundDate(LocalDate.now()) - .startTime(now.minusMinutes(2)) - .allowedMinutes(30) - .roundStatus(RoundStatus.ACTIVE) - .build(); - - Attendance existingAttendance = Attendance.builder() - .attendanceId(UUID.randomUUID()) - .user(user) - .attendanceSession(session) - .attendanceRound(round) - .attendanceStatus(AttendanceStatus.PRESENT) - .checkedAt(LocalDateTime.now()) - .build(); - - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .build(); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.of(round)); - when(attendanceRepository.findByAttendanceRound_RoundIdAndUser(roundId, user)) - .thenReturn(Optional.of(existingAttendance)); - - // when - AttendanceCheckInResponse response = attendanceService.checkInByRound(request, userId); - - // then - assertAll( - () -> assertThat(response.getSuccess()).isFalse(), - () -> assertThat(response.getFailureReason()).isEqualTo("이미 출석 체크인하셨습니다") - ); - } - - @Test - @DisplayName("라운드 기반 출석 체크인 실패 - 위치 불일치") - void checkInByRound_fail_locationMismatch() { - // given - UUID userId = UUID.randomUUID(); - UUID roundId = UUID.randomUUID(); - - User user = User.builder() - .userId(userId) - .name("테스트 사용자") - .build(); - - Location sessionLocation = Location.builder() - .lat(37.5665) - .lng(126.9780) - .radiusMeters(100) - .build(); - - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(UUID.randomUUID()) - .title("테스트 세션") - .code("123456") - .rewardPoints(10) - .location(sessionLocation) - .build(); - - LocalTime now = LocalTime.now(); - AttendanceRound round = AttendanceRound.builder() - .roundId(roundId) - .attendanceSession(session) - .roundDate(LocalDate.now()) - .startTime(now.minusMinutes(2)) - .allowedMinutes(30) - .roundStatus(RoundStatus.ACTIVE) - .build(); - - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .latitude(37.0000) // 다른 위치 - .longitude(126.0000) - .build(); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.of(round)); - when(attendanceRepository.findByAttendanceRound_RoundIdAndUser(roundId, user)).thenReturn(Optional.empty()); - - // when - AttendanceCheckInResponse response = attendanceService.checkInByRound(request, userId); - - // then - assertAll( - () -> assertThat(response.getSuccess()).isFalse(), - () -> assertThat(response.getFailureReason()).contains("위치 불일치") - ); - } - - @Test - @DisplayName("라운드 기반 출석 체크인 실패 - 위치 정보 누락") - void checkInByRound_fail_missingLocation() { - // given - UUID userId = UUID.randomUUID(); - UUID roundId = UUID.randomUUID(); - - User user = User.builder() - .userId(userId) - .name("테스트 사용자") - .build(); - - Location sessionLocation = Location.builder() - .lat(37.5665) - .lng(126.9780) - .radiusMeters(100) - .build(); - - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(UUID.randomUUID()) - .title("테스트 세션") - .code("123456") - .rewardPoints(10) - .location(sessionLocation) - .build(); - - LocalTime now = LocalTime.now(); - AttendanceRound round = AttendanceRound.builder() - .roundId(roundId) - .attendanceSession(session) - .roundDate(LocalDate.now()) - .startTime(now.minusMinutes(2)) - .allowedMinutes(30) - .roundStatus(RoundStatus.ACTIVE) - .build(); - - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .build(); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.of(round)); - when(attendanceRepository.findByAttendanceRound_RoundIdAndUser(roundId, user)).thenReturn(Optional.empty()); - - // when - AttendanceCheckInResponse response = attendanceService.checkInByRound(request, userId); - - // then - assertAll( - () -> assertThat(response.getSuccess()).isFalse(), - () -> assertThat(response.getFailureReason()).isEqualTo("위치 정보가 필요합니다") - ); - } } diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundServiceTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundServiceTest.java index b57c36c1..05de70fa 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundServiceTest.java @@ -41,268 +41,5 @@ public class AttendanceRoundServiceTest { @InjectMocks private AttendanceRoundService attendanceRoundService; - @Test - @DisplayName("라운드 생성 성공") - void createRound_success() { - // given - UUID sessionId = UUID.randomUUID(); - LocalDate roundDate = LocalDate.now().plusDays(1); - LocalTime startTime = LocalTime.of(14, 0); - AttendanceRoundRequest request = AttendanceRoundRequest.builder() - .roundDate(roundDate) - .startTime(startTime) - .allowedMinutes(30) - .build(); - - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("테스트 세션") - .code("123456") - .startsAt(LocalDateTime.now().plusDays(1)) - .build(); - - AttendanceRound savedRound = AttendanceRound.builder() - .roundId(UUID.randomUUID()) - .attendanceSession(session) - .roundDate(roundDate) - .startTime(startTime) - .allowedMinutes(30) - .roundStatus(RoundStatus.UPCOMING) - .build(); - - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(attendanceRoundRepository.save(any(AttendanceRound.class))).thenReturn(savedRound); - - // when - AttendanceRoundResponse response = attendanceRoundService.createRound(sessionId, request); - - // then - assertAll( - () -> assertThat(response.getRoundId()).isNotNull(), - () -> assertThat(response.getRoundDate()).isEqualTo(roundDate), - () -> assertThat(response.getStartTime()).isEqualTo(startTime), - () -> assertThat(response.getAvailableMinutes()).isEqualTo(30) - ); - - verify(attendanceSessionRepository).findById(sessionId); - verify(attendanceRoundRepository).save(any(AttendanceRound.class)); - } - - @Test - @DisplayName("라운드 생성 실패: 존재하지 않는 세션") - void createRound_fail_sessionNotFound() { - // given - UUID sessionId = UUID.randomUUID(); - AttendanceRoundRequest request = AttendanceRoundRequest.builder() - .roundDate(LocalDate.now().plusDays(1)) - .startTime(LocalTime.of(14, 0)) - .allowedMinutes(30) - .build(); - - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.empty()); - - // then - assertThatThrownBy(() -> attendanceRoundService.createRound(sessionId, request)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("세션을 찾을 수 없습니다: " + sessionId); - } - - @Test - @DisplayName("라운드 조회 성공") - void getRound_success() { - // given - UUID roundId = UUID.randomUUID(); - LocalDate roundDate = LocalDate.now().plusDays(1); - LocalTime startTime = LocalTime.of(14, 0); - - AttendanceRound round = AttendanceRound.builder() - .roundId(roundId) - .roundDate(roundDate) - .startTime(startTime) - .allowedMinutes(30) - .roundStatus(RoundStatus.UPCOMING) - .build(); - - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.of(round)); - - // when - AttendanceRoundResponse response = attendanceRoundService.getRound(roundId); - - // then - assertAll( - () -> assertThat(response.getRoundId()).isEqualTo(roundId), - () -> assertThat(response.getRoundDate()).isEqualTo(roundDate), - () -> assertThat(response.getStartTime()).isEqualTo(startTime) - ); - } - - @Test - @DisplayName("라운드 조회 실패: 존재하지 않는 라운드") - void getRound_fail_roundNotFound() { - // given - UUID roundId = UUID.randomUUID(); - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.empty()); - - // then - assertThatThrownBy(() -> attendanceRoundService.getRound(roundId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("라운드를 찾을 수 없습니다: " + roundId); - } - - @Test - @DisplayName("세션별 라운드 목록 조회 성공") - void getRoundsBySession_success() { - // given - UUID sessionId = UUID.randomUUID(); - LocalDate date1 = LocalDate.now().plusDays(1); - LocalDate date2 = LocalDate.now().plusDays(2); - LocalTime time = LocalTime.of(14, 0); - - List rounds = Arrays.asList( - AttendanceRound.builder() - .roundId(UUID.randomUUID()) - .roundDate(date1) - .startTime(time) - .allowedMinutes(30) - .roundStatus(RoundStatus.UPCOMING) - .build(), - AttendanceRound.builder() - .roundId(UUID.randomUUID()) - .roundDate(date2) - .startTime(time) - .allowedMinutes(30) - .roundStatus(RoundStatus.UPCOMING) - .build() - ); - - when(attendanceRoundRepository.findByAttendanceSession_AttendanceSessionIdOrderByRoundDateAsc(sessionId)) - .thenReturn(rounds); - - // when - List responses = attendanceRoundService.getRoundsBySession(sessionId); - - // then - assertAll( - () -> assertThat(responses).hasSize(2), - () -> assertThat(responses.get(0).getRoundDate()).isEqualTo(date1), - () -> assertThat(responses.get(1).getRoundDate()).isEqualTo(date2) - ); - } - - @Test - @DisplayName("라운드 수정 성공") - void updateRound_success() { - // given - UUID roundId = UUID.randomUUID(); - LocalDate newDate = LocalDate.now().plusDays(3); - LocalTime newTime = LocalTime.of(15, 0); - - AttendanceRoundRequest request = AttendanceRoundRequest.builder() - .roundDate(newDate) - .startTime(newTime) - .allowedMinutes(45) - .build(); - - AttendanceRound existingRound = AttendanceRound.builder() - .roundId(roundId) - .roundDate(LocalDate.now().plusDays(1)) - .startTime(LocalTime.of(14, 0)) - .allowedMinutes(30) - .roundStatus(RoundStatus.UPCOMING) - .build(); - - AttendanceRound updatedRound = existingRound.toBuilder() - .roundDate(newDate) - .startTime(newTime) - .allowedMinutes(45) - .build(); - - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.of(existingRound)); - when(attendanceRoundRepository.save(any(AttendanceRound.class))).thenReturn(updatedRound); - - // when - AttendanceRoundResponse response = attendanceRoundService.updateRound(roundId, request); - - // then - assertAll( - () -> assertThat(response.getRoundDate()).isEqualTo(newDate), - () -> assertThat(response.getStartTime()).isEqualTo(newTime), - () -> assertThat(response.getAvailableMinutes()).isEqualTo(45) - ); - - verify(attendanceRoundRepository).save(any(AttendanceRound.class)); - } - - @Test - @DisplayName("라운드 삭제 성공") - void deleteRound_success() { - // given - UUID roundId = UUID.randomUUID(); - UUID sessionId = UUID.randomUUID(); - - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("테스트 세션") - .build(); - - AttendanceRound round = AttendanceRound.builder() - .roundId(roundId) - .attendanceSession(session) - .roundDate(LocalDate.now().plusDays(1)) - .startTime(LocalTime.of(14, 0)) - .build(); - - when(attendanceRoundRepository.findRoundById(roundId)).thenReturn(Optional.of(round)); - - // when - attendanceRoundService.deleteRound(roundId); - - // then - verify(attendanceRoundRepository).delete(round); - } - - @Test - @DisplayName("특정 날짜의 라운드 조회 성공") - void getRoundByDate_success() { - // given - UUID sessionId = UUID.randomUUID(); - LocalDate targetDate = LocalDate.now().plusDays(1); - - AttendanceRound round = AttendanceRound.builder() - .roundId(UUID.randomUUID()) - .roundDate(targetDate) - .startTime(LocalTime.of(14, 0)) - .allowedMinutes(30) - .roundStatus(RoundStatus.UPCOMING) - .build(); - - when(attendanceRoundRepository.findByAttendanceSession_AttendanceSessionIdAndRoundDate(sessionId, targetDate)) - .thenReturn(Optional.of(round)); - - // when - AttendanceRoundResponse response = attendanceRoundService.getRoundByDate(sessionId, targetDate); - - // then - assertAll( - () -> assertThat(response.getRoundDate()).isEqualTo(targetDate), - () -> assertThat(response.getStartTime()).isEqualTo(LocalTime.of(14, 0)) - ); - } - - @Test - @DisplayName("특정 날짜의 라운드 조회 실패: 존재하지 않는 라운드") - void getRoundByDate_fail_roundNotFound() { - // given - UUID sessionId = UUID.randomUUID(); - LocalDate targetDate = LocalDate.now().plusDays(1); - - when(attendanceRoundRepository.findByAttendanceSession_AttendanceSessionIdAndRoundDate(sessionId, targetDate)) - .thenReturn(Optional.empty()); - - // then - assertThatThrownBy(() -> attendanceRoundService.getRoundByDate(sessionId, targetDate)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("해당 날짜의 라운드를 찾을 수 없습니다"); - } } diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java index 57c55d76..d84406b4 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java @@ -35,318 +35,7 @@ public class AttendanceServiceTest { @InjectMocks private AttendanceService attendanceService; - @Test - void 체크인_성공() { - //given - String code = "123456"; - AttendanceRequest request = AttendanceRequest.builder() - .code(code) - .latitude(37.5665) - .longitude(126.9780) - .note("정상 출석") - .deviceInfo("IPhone 14") - .build(); - User user = User.builder() - .userId(UUID.randomUUID()) - .name("오찬혁") - .email("oh@naver.com") - .role(Role.TEAM_MEMBER) - .build(); - Location sessionLocation = Location.builder() - .lat(37.5665) - .lng(126.9780) - .radiusMeters(100) - .build(); - LocalDateTime now = LocalDateTime.now(); - UUID sessionId = UUID.randomUUID(); - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("세투연 정기모임") - .code(code) - .startsAt(now.minusMinutes(5)) - .windowSeconds(1800) - .rewardPoints(10) - .location(sessionLocation) - .status(SessionStatus.OPEN) - .build(); - - Attendance savedAttendance = Attendance.builder() - .attendanceId(UUID.randomUUID()) - .user(user) - .attendanceSession(session) - .attendanceStatus(AttendanceStatus.LATE) - .checkedAt(now) - .awardedPoints(10) - .note("정상 출석") - .deviceInfo("IPhone 14") - .build(); - - when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(attendanceRepository.existsByAttendanceSessionAndUser(session, user)).thenReturn(false); - when(attendanceRepository.save(any(Attendance.class))).thenReturn(savedAttendance); - - //when - AttendanceResponse response = attendanceService.checkIn(sessionId, request, user.getUserId()); - - //then - assertAll( - () -> assertThat(response.getAttendanceStatus()).isEqualTo(AttendanceStatus.LATE), - () -> assertThat(response.getUserName()).isEqualTo("오찬혁"), - () -> assertThat(response.getAwardedPoints()).isEqualTo(10), - () -> assertThat(response.getNote()).isEqualTo("정상 출석"), - () -> assertThat(response.getDeviceInfo()).isEqualTo("IPhone 14") - ); - - verify(attendanceRepository).save(any(Attendance.class)); - } - - @Test - void 체크인_실패_존재하지_않는_코드() { - //given - String invalidCode = "999999"; - AttendanceRequest request = AttendanceRequest.builder() - .code(invalidCode) - .latitude(37.5665) - .longitude(126.9780) - .build(); - - User user = User.builder() - .userId(UUID.randomUUID()) - .name("오찬혁") - .build(); - - UUID sessionId = UUID.randomUUID(); - when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.empty()); - - // then - assertThatThrownBy(() -> attendanceService.checkIn(sessionId, request, user.getUserId())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 세션입니다: " + sessionId); - - // attendanceRepository.save() 메서드가 호출되지 않았는지 검증 - verify(attendanceRepository, never()).save(any()); - } - - @Test - void 체크인_실패_중복출석() { - //given - String code = "123456"; - AttendanceRequest request = AttendanceRequest.builder() - .code(code) - .latitude(37.5665) - .longitude(126.9780) - .build(); - - User user = User.builder() - .userId(UUID.randomUUID()) - .name("오찬혁") - .build(); - - UUID sessionId = UUID.randomUUID(); - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .code(code) - .build(); - - when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(attendanceRepository.existsByAttendanceSessionAndUser(session, user)).thenReturn(true); - - //then - assertThatThrownBy(() -> attendanceService.checkIn(sessionId, request, user.getUserId())) - .isInstanceOf(IllegalStateException.class) - .hasMessage("이미 출석 체크인한 세션입니다"); - - verify(attendanceRepository, never()).save(any()); - } - - @Test - void 체크인_실패_위치범위초과() { - //given - String code = "123456"; - AttendanceRequest request = AttendanceRequest.builder() - .code(code) - .latitude(37.6665) - .longitude(127.0780) - .build(); - - User user = User.builder() - .userId(UUID.randomUUID()) - .name("오찬혁") - .build(); - - Location sessionLocation = Location.builder() - .lat(37.5665) - .lng(126.9780) - .radiusMeters(100) - .build(); - - UUID sessionId = UUID.randomUUID(); - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .code(code) - .location(sessionLocation) - .build(); - - when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(attendanceRepository.existsByAttendanceSessionAndUser(session, user)).thenReturn(false); - - //then - assertThatThrownBy(() -> attendanceService.checkIn(sessionId, request, user.getUserId())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("출석 허용 범위를 벗어났습니다"); - - verify(attendanceRepository, never()).save(any()); - } - - @Test - void 세션별_출석_목록_조회() { - //given - UUID sessionId = UUID.randomUUID(); - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("세투연 정기모임") - .build(); - - User user1 = User.builder().userId(UUID.randomUUID()).name("오찬혁").build(); - User user2 = User.builder().userId(UUID.randomUUID()).name("김찬혁").build(); - - List attendances = Arrays.asList( - Attendance.builder() - .attendanceId(UUID.randomUUID()) - .user(user1) - .attendanceSession(session) - .attendanceStatus(AttendanceStatus.PRESENT) - .checkedAt(LocalDateTime.now().minusHours(1)) - .build(), - Attendance.builder() - .attendanceId(UUID.randomUUID()) - .user(user2) - .attendanceSession(session) - .attendanceStatus(AttendanceStatus.LATE) - .checkedAt(LocalDateTime.now()) - .build() - ); - - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(attendanceRepository.findByAttendanceSessionOrderByCheckedAtAsc(session)).thenReturn(attendances); - - //when - List response = attendanceService.getAttendancesBySession(sessionId); - - //then - assertAll( - () -> assertThat(response).hasSize(2), - () -> assertThat(response.get(0).getUserName()).isEqualTo("오찬혁"), - () -> assertThat(response.get(0).getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT), - () -> assertThat(response.get(1).getUserName()).isEqualTo("김찬혁"), - () -> assertThat(response.get(1).getAttendanceStatus()).isEqualTo(AttendanceStatus.LATE) - ); - } - - @Test - void 출석_상태_수정성공() { - //given - UUID sessionId = UUID.randomUUID(); - UUID memberId = UUID.randomUUID(); - String newStatus = "PRESENT"; - String reason = "관리자 수정"; - - User adminUser = User.builder() - .userId(UUID.randomUUID()) - .name("관리자") - .role(Role.PRESIDENT) - .build(); - - User student = User.builder() - .userId(memberId) - .name("오찬혁") - .build(); - - AttendanceSession session = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("세투연 정기모임") - .build(); - - Attendance attendance = Attendance.builder() - .attendanceId(UUID.randomUUID()) - .user(student) - .attendanceSession(session) - .attendanceStatus(AttendanceStatus.LATE) - .checkedAt(LocalDateTime.now()) - .build(); - - when(userRepository.findById(adminUser.getUserId())).thenReturn(Optional.of(adminUser)); - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); - when(attendanceRepository.findByAttendanceSessionAndUser_UserId(session, memberId)).thenReturn(Optional.of(attendance)); - when(attendanceRepository.save(any(Attendance.class))).thenReturn(attendance); - - //when - AttendanceResponse response = attendanceService.updateAttendanceStatus( - sessionId, memberId, newStatus, reason, adminUser.getUserId()); - - //then - assertAll( - () -> assertThat(response.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT), - () -> assertThat(response.getNote()).isEqualTo(reason) - ); - - verify(attendanceRepository).save(attendance); - } - - @Test - void 사용자별_출석_이력_조회() { - //given - User user = User.builder() - .userId(UUID.randomUUID()) - .name("오찬혁") - .build(); - - AttendanceSession session1 = AttendanceSession.builder() - .attendanceSessionId(UUID.randomUUID()) - .title("세션1") - .build(); - - AttendanceSession session2 = AttendanceSession.builder() - .attendanceSessionId(UUID.randomUUID()) - .title("세션2") - .build(); - - List attendances = Arrays.asList( - Attendance.builder() - .attendanceId(UUID.randomUUID()) - .user(user) - .attendanceSession(session1) - .attendanceStatus(AttendanceStatus.PRESENT) - .checkedAt(LocalDateTime.now().minusDays(1)) - .build(), - Attendance.builder() - .attendanceId(UUID.randomUUID()) - .user(user) - .attendanceSession(session2) - .attendanceStatus(AttendanceStatus.LATE) - .checkedAt(LocalDateTime.now()) - .build() - ); - - when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); - when(attendanceRepository.findByUserOrderByCheckedAtDesc(user)).thenReturn(attendances); - - //when - List responses = attendanceService.getAttendancesByUser(user.getUserId()); - - //then - assertAll( - () -> assertThat(responses).hasSize(2), - () -> assertThat(responses.get(0).getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT), - () -> assertThat(responses.get(1).getAttendanceStatus()).isEqualTo(AttendanceStatus.LATE) - ); - - } } From 365ee6d07106fe221c38d3e9df4cdfa33b4c0295 Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Mon, 19 Jan 2026 17:00:50 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat(=EC=84=B8=EC=85=98):=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=EA=B8=B0=EB=8A=A5=EB=A7=8C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AttendanceController.java | 152 +++--- .../controller/AttendanceRoundController.java | 266 ++------- .../AttendanceSessionController.java | 315 ++++------- .../controller/SessionUserController.java | 103 ++++ .../dto/AttendanceCheckInRequest.java | 49 -- .../attendance/dto/AttendanceRequest.java | 62 --- .../attendance/dto/AttendanceResponse.java | 141 ++--- .../dto/AttendanceRoundQrTokenResponse.java | 9 + .../dto/AttendanceRoundRequest.java | 64 +-- .../dto/AttendanceRoundResponse.java | 97 +--- .../dto/AttendanceSessionRequest.java | 106 +--- .../dto/AttendanceSessionResponse.java | 105 ++-- .../dto/RoundAttendanceResponse.java | 31 -- .../attendance/dto/SessionUserRequest.java | 19 - .../attendance/dto/SessionUserResponse.java | 39 +- .../backend/attendance/entity/Attendance.java | 22 +- .../attendance/entity/AttendanceRound.java | 37 +- .../attendance/entity/AttendanceSession.java | 13 +- .../attendance/entity/SessionRole.java | 1 + .../attendance/entity/SessionStatus.java | 1 - .../attendance/entity/SessionUser.java | 7 + .../repository/AttendanceRepository.java | 46 +- .../repository/AttendanceRoundRepository.java | 41 +- .../AttendanceSessionRepository.java | 6 +- .../repository/SessionUserRepository.java | 40 +- .../AttendanceAuthorizationService.java | 57 ++ .../service/AttendanceRoundService.java | 282 +++++----- .../attendance/service/AttendanceService.java | 511 ++++++++---------- .../service/AttendanceSessionService.java | 222 +++----- .../service/SessionUserService.java | 263 ++++----- .../backend/attendance/util/QrTokenUtil.java | 83 +++ .../backend/common/exception/ErrorCode.java | 6 +- .../controller/AttendanceControllerTest.java | 207 ------- .../AttendanceRoundControllerTest.java | 357 +----------- .../service/AttendanceRoundCheckInTest.java | 3 - .../service/AttendanceRoundServiceTest.java | 18 - .../service/AttendanceServiceTest.java | 2 - .../service/SessionLocationUpdateTest.java | 174 ------ 38 files changed, 1322 insertions(+), 2635 deletions(-) create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java delete mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInRequest.java delete mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRequest.java create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundQrTokenResponse.java delete mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/dto/RoundAttendanceResponse.java delete mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserRequest.java create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceAuthorizationService.java create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/util/QrTokenUtil.java 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 48c3b817..c57a75f1 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,109 +2,99 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.attendance.dto.AttendanceResponse; +import org.sejongisc.backend.attendance.dto.AttendanceStatusUpdateRequest; import org.sejongisc.backend.attendance.service.AttendanceService; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; -import java.util.UUID; - @RestController -@RequiredArgsConstructor @RequestMapping("/api/attendance") +@RequiredArgsConstructor @Slf4j -@Tag( - name = "출석(Attendance) API", - description = "학생 출석 체크인 및 관리자 출석 현황 조회 관련 API" -) +@Tag(name = "출석(Attendance) API", description = "체크인, 출석명단 조회, 출석상태 수정 등 출석 관련 API") public class AttendanceController { - private final AttendanceService attendanceService; - - /** - * 세션별 출석 목록 조회(관리자용) - * @deprecated 라운드 기반 조회로 변경되었습니다. - * GET /api/attendance/rounds/{roundId}/attendances 를 사용하세요. - * - 특정 세션의 모든 출석 기록 조회 - * - 출석 시간 순으로 정렬 - */ - @Operation( - summary = "세션별 출석 목록 조회", - 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> getAttendancesBySession(@PathVariable UUID sessionId) { - log.info("세션별 출석 목록 조회: 세션ID={}", sessionId); + private final AttendanceService attendanceService; - List attendances = attendanceService.getAttendancesBySession(sessionId); + /** + * ✅ 체크인(세션 멤버) + * POST /api/attendance/check-in + * body: { "qrToken": "..." } + */ + @Operation(summary = "체크인", description = "qrToken으로 출석 체크인합니다. (세션 멤버)") + @PostMapping("/check-in") + public ResponseEntity checkIn( + @AuthenticationPrincipal CustomUserDetails userDetails, + String qrToken + ) { + UUID userId = requireUserId(userDetails); + attendanceService.checkIn(userId, qrToken); + return ResponseEntity.ok().build(); + } - return ResponseEntity.ok(attendances); - } + /** + * 라운드별 출석 명단 조회(관리자/OWNER) + */ + @Operation(summary = "라운드 출석 명단 조회", description = "특정 라운드의 출석 기록을 조회합니다. (관리자/OWNER)") + @GetMapping("/rounds/{roundId}") + public ResponseEntity> getAttendancesByRound( + @PathVariable UUID roundId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID adminUserId = requireUserId(userDetails); + return ResponseEntity.ok(attendanceService.getAttendancesByRound(roundId, adminUserId)); + } - /** - * 내 출석 기록 조회 - * - 로그인한 사용자의 모든 출석 기록 조회 - * - 최신 순으로 정렬 - */ - @Operation( - summary = "내 출석 기록 조회", - description = "로그인한 사용자의 모든 출석 기록을 최신 순으로 조회합니다. " + - "각 출석 기록에는 세션 정보, 출석 상태, 체크인 시간, 획득 포인트 등이 포함됩니다." - ) - @GetMapping("/history") - public ResponseEntity> getMyAttendances( - @AuthenticationPrincipal CustomUserDetails userDetails) { - log.info("내 출석 기록 조회: 사용자={}", userDetails.getName()); + /** + * 라운드 내 특정 유저 출석 상태 수정(관리자/OWNER) + * PUT /api/attendance/rounds/{roundId}/users/{userId} + */ + @Operation(summary = "출석 상태 수정", description = "특정 라운드에서 특정 유저의 출석 상태를 수정합니다. (관리자/OWNER)") + @PutMapping("/rounds/{roundId}/users/{userId}") + public ResponseEntity updateAttendanceStatus( + @PathVariable UUID roundId, + @PathVariable UUID userId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody AttendanceStatusUpdateRequest request + ) { + UUID adminUserId = requireUserId(userDetails); - List attendances = attendanceService.getAttendancesByUser(userDetails.getUserId()); + // status가 enum이든 string이든 안전하게 문자열로 변환 + String statusStr = String.valueOf(request.getStatus()); + String reason = request.getReason(); - return ResponseEntity.ok(attendances); - } + AttendanceResponse response = + attendanceService.updateAttendanceStatusByRound(adminUserId, roundId, userId, statusStr, reason); - /** - * 출석 상태 수정(관리자용) - * @deprecated 라운드 기반 수정으로 변경되었습니다. - * PUT /api/attendance/rounds/{roundId}/attendances/{userId} 를 사용하세요. - * - PRESENT/LATE/ABSENT 등으로 상태 변경 - * - 수정 사유 기록 가능 - */ - @Operation( - summary = "출석 상태 수정", - 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 updateAttendanceStatus( - @PathVariable UUID sessionId, - @PathVariable UUID memberId, - @RequestParam String status, - @RequestParam(required = false) String reason, - @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(response); + } - log.info("출석 상태 수정: 세션ID={}, 멤버ID={}, 새로운상태={}, 관리자={}", sessionId, memberId, status, userDetails.getName()); + /** + * (옵션) 내 출석 이력 조회 + * GET /api/attendance/me + */ + @Operation(summary = "내 출석 이력 조회", description = "로그인한 사용자의 출석 이력을 조회합니다.") + @GetMapping("/me") + public ResponseEntity> getMyAttendances( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID userId = requireUserId(userDetails); + return ResponseEntity.ok(attendanceService.getAttendancesByUser(userId)); + } - AttendanceResponse response = attendanceService.updateAttendanceStatus(sessionId, memberId, status, reason, userDetails.getUserId()); - log.info("출석 상태 수정 완료: 세션ID={}, 멤버ID={}, 상태={}", sessionId, memberId, response.getAttendanceStatus()); - return ResponseEntity.ok(response); - } + // ------- helper ------- + private UUID requireUserId(CustomUserDetails userDetails) { + if (userDetails == null) throw new IllegalStateException("UNAUTHENTICATED"); + return userDetails.getUserId(); + } } 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 a1fc034a..c553a6f8 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 @@ -1,276 +1,126 @@ 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.*; import org.sejongisc.backend.attendance.service.AttendanceRoundService; import org.sejongisc.backend.attendance.service.AttendanceService; -import org.sejongisc.backend.common.auth.jwt.JwtProvider; +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.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.time.LocalDate; import java.util.List; import java.util.UUID; @RestController -@RequestMapping("/api/attendance") +@RequestMapping("/api/attendance/round") @RequiredArgsConstructor @Slf4j @Tag( - name = "출석 라운드(Attendance Round) API", - description = "출석 라운드(주차별 회차) 생성, 조회, 수정, 삭제 및 출석 체크인 관련 API" + name = "출석 라운드(Attendance Round) API", + description = "출석 라운드(주차별 회차) 생성, 조회, 수정, 삭제 및 출석 체크인 관련 API" ) public class AttendanceRoundController { private final AttendanceRoundService attendanceRoundService; private final AttendanceService attendanceService; - private final JwtProvider jwtProvider; /** - * 라운드 생성 + * 라운드 생성 (관리자/OWNER) * POST /api/attendance/sessions/{sessionId}/rounds */ - @Operation( - summary = "라운드 생성", - description = "세션에 새로운 출석 라운드를 생성합니다. " + - "라운드 날짜, 시작 시간, 출석 가능 시간을 설정할 수 있습니다." - ) + @Operation(summary = "라운드 생성", description = "세션에 새로운 출석 라운드를 생성합니다. (관리자/OWNER)") @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); + @PathVariable UUID sessionId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody AttendanceRoundRequest request + ) { + UUID userId = requireUserId(userDetails); + + AttendanceRoundResponse created = attendanceRoundService.createRound(sessionId, userId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(created); } /** - * 라운드 조회 (개별) + * 라운드 조회 (세션 멤버) * GET /api/attendance/rounds/{roundId} */ - @Operation( - summary = "라운드 조회", - description = "지정된 라운드 ID로 라운드 정보를 조회합니다. " + - "라운드의 상태, 날짜, 시간, 참석 현황 등의 정보를 반환합니다." - ) + @Operation(summary = "라운드 조회", description = "지정된 라운드 ID로 라운드 정보를 조회합니다. (세션 멤버)") @GetMapping("/rounds/{roundId}") - public ResponseEntity getRound(@PathVariable UUID roundId) { + public ResponseEntity getRound( + @PathVariable UUID roundId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID userId = requireUserId(userDetails); + log.info("라운드 조회: roundId={}", roundId); - AttendanceRoundResponse response = attendanceRoundService.getRound(roundId); + AttendanceRoundResponse response = attendanceRoundService.getRound(roundId, userId); return ResponseEntity.ok(response); } /** - * 세션 내 라운드 목록 조회 + * 세션 내 라운드 목록 조회 (세션 멤버) * GET /api/attendance/sessions/{sessionId}/rounds */ - @Operation( - summary = "세션의 라운드 목록 조회", - description = "지정된 세션에 속한 모든 라운드 목록을 조회합니다. " + - "각 라운드의 상태, 시간, 참석 현황을 포함합니다." - ) + @Operation(summary = "세션의 라운드 목록 조회", description = "지정된 세션에 속한 모든 라운드 목록을 조회합니다. (세션 멤버)") @GetMapping("/sessions/{sessionId}/rounds") public ResponseEntity> getRoundsBySession( - @PathVariable UUID sessionId) { + @PathVariable UUID sessionId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID userId = requireUserId(userDetails); + log.info("세션 내 라운드 목록 조회: sessionId={}", sessionId); - List response = attendanceRoundService.getRoundsBySession(sessionId); + List response = attendanceRoundService.getRoundsBySession(sessionId, userId); return ResponseEntity.ok(response); } /** - * 라운드 정보 수정 - * PUT /api/attendance/rounds/{roundId} + * QR 토큰 발급 (관리자/OWNER) + * - 서버가 짧게 유효한 qrToken 발급 + * - 참가자에게는 토큰만 전달(사진 공유해도 만료되면 무효) */ - @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); + @Operation(summary = "QR 토큰 발급", description = "짧게 유효한 QR 토큰(qrToken)을 발급합니다. (관리자/OWNER, 라운드 ACTIVE 권장)") + @GetMapping("/rounds/{roundId}/qr-token") + public ResponseEntity issueQrToken( + @PathVariable UUID roundId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID userId = requireUserId(userDetails); + + AttendanceRoundQrTokenResponse response = attendanceRoundService.issueQrToken(roundId, userId); return ResponseEntity.ok(response); } + /** - * 라운드 삭제 + * 라운드 삭제 (관리자/OWNER) * DELETE /api/attendance/rounds/{roundId} */ - @Operation( - summary = "라운드 삭제", - description = "지정된 라운드를 삭제합니다. " + - "라운드와 관련된 모든 출석 기록도 함께 삭제됩니다." - ) + @Operation(summary = "라운드 삭제", description = "지정된 라운드를 삭제합니다. (관리자/OWNER)") @DeleteMapping("/rounds/{roundId}") - @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") - public ResponseEntity deleteRound(@PathVariable UUID roundId) { + public ResponseEntity deleteRound( + @PathVariable UUID roundId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID userId = requireUserId(userDetails); + log.info("라운드 삭제: roundId={}", roundId); - attendanceRoundService.deleteRound(roundId); + attendanceRoundService.deleteRound(roundId, userId); 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", "라운드를 찾을 수 없습니다"); - }}); - } - } - - /** - * 라운드의 출석 상태 수정 (관리자용) - * 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()); + // -------- private helpers -------- - log.info("출석 상태 수정 완료: roundId={}, userId={}", roundId, userId); - - return ResponseEntity.ok(response); + private UUID requireUserId(CustomUserDetails userDetails) { + if (userDetails == null) throw new IllegalStateException("UNAUTHENTICATED"); + return userDetails.getUserId(); } } 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 413a15f5..bb4eaf26 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 @@ -6,13 +6,16 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.attendance.dto.*; +import org.sejongisc.backend.attendance.service.AttendanceAuthorizationService; import org.sejongisc.backend.attendance.service.AttendanceSessionService; import org.sejongisc.backend.attendance.service.SessionUserService; +import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.user.service.UserService; import org.sejongisc.backend.user.service.projection.UserIdNameProjection; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -32,45 +35,60 @@ public class AttendanceSessionController { private final SessionUserService sessionUserService; private final UserService userService; /** - * 출석 세션 생성 (관리자용) - * - 6자리 랜덤 코드 자동 생성 - * - GPS 위치 및 반경 설정 - * - 시간 윈도우 설정 + * 출석 세션 생성 */ @Operation( summary = "출석 세션 생성", - description = "새로운 출석 세션을 생성합니다. (관리자 전용) " + - "6자리 랜덤 코드가 자동 생성되며, GPS 위치 정보, 시간 윈도우, " + - "보상 포인트 등을 설정할 수 있습니다." + description = """ + + ## 인증(JWT): **필요** + + + ## 요청 파라미터 ( `AttendanceSessionDto` ) + - **`title`**: 세션 제목 + - **`description`**: 세션 설명 + - **`allowedMinutes`**: 체크인 허용 시간 (분) + - **`rewardPoints`**: 세션 참여 시 부여할 포인트 + + ## 반환값 없음 + """ ) @PostMapping - @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") - public ResponseEntity createSession(@Valid @RequestBody AttendanceSessionRequest request) { - log.info("출석 세션 생성 요청: 제목={}", request.getTitle()); - - AttendanceSessionResponse response = attendanceSessionService.createSession(request); + public ResponseEntity createSession(@AuthenticationPrincipal(expression = "userId") UUID userId, + @RequestBody AttendanceSessionRequest request) { + log.info("출석 세션 생성 요청: 제목={}", request.title()); - log.info("출석 세션 생성 완료: 세션ID={}", response.getAttendanceSessionId()); + attendanceSessionService.createSession(userId, request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + return ResponseEntity.status(HttpStatus.CREATED).build(); } /** * 세션 상세 조회 * - 세션 ID로 상세 정보 조회 - * - 남은 시간, 참여자 수 등 포함 */ @Operation( summary = "세션 상세 조회", - description = "세션 ID로 특정 세션의 상세 정보를 조회합니다. " + - "남은 체크인 시간, 현재 참여자 수, 세션 상태 등의 정보가 포함됩니다." + description = """ + ## 인증(JWT): **필요없음** + + ## 요청 파라미터 ( `sessionId` ) + + ## 반환값 (`AttendanceSessionDto`) + - **`title`**: 세션 제목 + - **`description`**: 세션 설명 + - **`allowedMinutes`**: 체크인 허용 시간 (분) + - **`rewardPoints`**: 세션 참여 시 부여할 포인트 + - **`status`**: 세션 상태 (OPEN, CLOSED 등) + """ ) @GetMapping("/{sessionId}") - public ResponseEntity getSession(@PathVariable UUID sessionId) { - log.info("출석 세션 조회: 세션ID={}", sessionId); - - AttendanceSessionResponse response = attendanceSessionService.getSessionById(sessionId); - + public ResponseEntity getSession( + @PathVariable UUID sessionId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID userId = (userDetails != null) ? userDetails.getUserId() : null; + AttendanceSessionResponse response = attendanceSessionService.getSessionById(sessionId, userId); return ResponseEntity.ok(response); } @@ -81,46 +99,50 @@ public ResponseEntity getSession(@PathVariable UUID s */ @Operation( summary = "모든 세션 목록 조회", - description = "생성된 모든 출석 세션을 최신 순으로 조회합니다. " + - "공개/비공개 세션이 모두 포함되며, 관리자는 모든 세션을 볼 수 있습니다." + description = """ + ## 인증(JWT): **필요없음** + + ## 요청 파라미터 : **없음** + + ## 반환값 (`List`) + - **`title`**: 세션 제목 + - **`description`**: 세션 설명 + - **`allowedMinutes`**: 체크인 허용 시간 (분) + - **`rewardPoints`**: 세션 참여 시 부여할 포인트 + - **`status`**: 세션 상태 (OPEN, CLOSED 등) + + ## 설명 + - 세션 관리자 권한 여부에 따라 반환되는 세션 정보가 다를 수 있음 + """ ) @GetMapping public ResponseEntity> getAllSessions() { - log.info("모든 출석 세션 조회"); List sessions = attendanceSessionService.getAllSessions(); - return ResponseEntity.ok(sessions); - } - - /** - * 공개 세션 목록 조회 - * - 학생들이 볼 수 있는 공개 세션만 조회 - * - 최신 순으로 정렬 - */ - @Operation( - summary = "공개 세션 목록 조회", - description = "학생들이 볼 수 있는 공개 세션들을 최신 순으로 조회합니다. " + - "비공개 세션은 제외됩니다." - ) - @GetMapping("/public") - public ResponseEntity> getPublicSessions() { - log.info("공개 출석 세션 조회"); - - List sessions = attendanceSessionService.getPublicSessions(); return ResponseEntity.ok(sessions); } + /** * 현재 활성 세션 목록 조회 * - 체크인 가능한 세션들만 조회 - * - 시작시간 ~ 종료 시간 범위 내 */ @Operation( summary = "활성 세션 목록 조회", - description = "현재 체크인이 가능한 활성 세션들을 조회합니다. " + - "세션 시작 시간부터 시간 윈도우 종료까지 범위 내인 세션들만 조회됩니다." + description = """ + ## 인증(JWT): **필요없음** + + ## 요청 파라미터 : **없음** + + ## 반환값 (`List`) + - **`title`**: 세션 제목 + - **`description`**: 세션 설명 + - **`allowedMinutes`**: 체크인 허용 시간 (분) + - **`rewardPoints`**: 세션 참여 시 부여할 포인트 + - **`status`**: 세션 상태 (OPEN, CLOSED 등) + """ ) @GetMapping("/active") public ResponseEntity> getActiveSessions() { @@ -138,47 +160,25 @@ public ResponseEntity> getActiveSessions() { */ @Operation( summary = "세션 정보 수정", - description = "세션의 기본 정보를 수정합니다. (관리자 전용) " + - "제목, 태그, 시간, GPS 위치, 반경, 포인트 등을 수정할 수 있으며, " + - "6자리 코드는 변경할 수 없습니다." + description = """ + + """ ) @PutMapping("/{sessionId}") - @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") - public ResponseEntity updateSession( + public ResponseEntity updateSession( @PathVariable UUID sessionId, - @Valid @RequestBody AttendanceSessionRequest request) { + @RequestBody AttendanceSessionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails){ log.info("출석 세션 수정: 세션ID={}", sessionId); - - AttendanceSessionResponse response = attendanceSessionService.updateSession(sessionId, request); + attendanceSessionService.updateSession(sessionId, request,userDetails.getUserId()); log.info("출석 세션 수정 완료: 세션ID={}", sessionId); - return ResponseEntity.ok(response); - } - - /** - * 세션 활성화 (관리자용) - * - 세션 상태를 OPEN으로 변경 - * - 체크인 수동 활성화 - */ - @Operation( - summary = "세션 활성화", - description = "세션을 수동으로 활성화하여 즉시 체크인을 가능하게 합니다. (관리자 전용) " + - "세션 상태가 OPEN으로 변경되어 학생들이 체크인할 수 있습니다." - ) - @PostMapping("/{sessionId}/activate") - @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") - public ResponseEntity activateSession(@PathVariable UUID sessionId) { - log.info("출석 세션 활성화: 세션ID={}", sessionId); - - attendanceSessionService.activateSession(sessionId); - - log.info("출석 세션 활성화 완료: 세션ID={}", sessionId); - return ResponseEntity.ok().build(); } + /** * 세션 종료 (관리자용) * - 세션 상태를 CLOSED로 변경 @@ -186,45 +186,23 @@ public ResponseEntity activateSession(@PathVariable UUID sessionId) { */ @Operation( summary = "세션 종료", - description = "세션을 종료합니다. (관리자 전용) " + - "세션 상태가 CLOSED로 변경되어 더 이상 체크인이 불가능합니다." + description = """ + ## 인증(JWT): **필요** + + """ ) @PostMapping("/{sessionId}/close") - @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") - public ResponseEntity closeSession(@PathVariable UUID sessionId) { + public ResponseEntity closeSession(@PathVariable UUID sessionId,@AuthenticationPrincipal CustomUserDetails userDetails) { log.info("출석 세션 종료: 세션ID={}", sessionId); - attendanceSessionService.closeSession(sessionId); + + attendanceSessionService.closeSession(sessionId,userDetails.getUserId()); log.info("출석 세션 종료 완료: 세션ID={}", 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); - } /** * 세션 삭제 (관리자용) @@ -233,112 +211,47 @@ public ResponseEntity updateSessionLocation( */ @Operation( summary = "세션 삭제", - description = "세션을 완전히 삭제합니다. (관리자 전용) " + - "⚠️ 주의: 해당 세션의 모든 출석 기록이 함께 삭제되며, 복구가 불가능합니다." + description = """ + ## 인증(JWT): **필요** + + """ ) @DeleteMapping("/{sessionId}") - @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')") - public ResponseEntity deleteSession(@PathVariable UUID sessionId) { + public ResponseEntity deleteSession(@PathVariable UUID sessionId, + @AuthenticationPrincipal CustomUserDetails userDetails) { log.info("출석 세션 삭제: 세션ID={}", sessionId); - attendanceSessionService.deleteSession(sessionId); + attendanceSessionService.deleteSession(sessionId,userDetails.getUserId()); log.info("출석 세션 삭제 완료: 세션ID={}", 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); - } + // /** +// * 세션 위치 재설정 (관리자용) +// * - 기존 위치 정보를 새로운 위치로 업데이트 +// * - 반경은 기존 값 유지 +// */ +// @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); +// } - @GetMapping("/get-users") - @Operation( - summary = "유저 목록 반환", - description = """ - ## 인증(JWT): **필요** - - ## 설명 - - 모든 유저의 id와 이름을 반환 - - ## 요청 파라미터 - - **요청 파라미터 없음** - - ## 반환값 - - UUID userId - String name - String email - """ - ) - @PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT') or hasRole('TEAM_LEADER')") - public ResponseEntity getUsers(){ - List userProjectionList = userService.getUserProjectionList(); - return ResponseEntity.ok(userProjectionList); - } } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java new file mode 100644 index 00000000..789c2311 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java @@ -0,0 +1,103 @@ +package org.sejongisc.backend.attendance.controller; + +import io.swagger.v3.oas.annotations.Operation; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.attendance.dto.SessionUserResponse; +import org.sejongisc.backend.attendance.service.SessionUserService; +import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.user.service.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/attendance/sessions/users") +public class SessionUserController { + private final SessionUserService sessionUserService; + /** + * 세션에 사용자 추가 (관리자용) + * - 사용자를 세션에 추가 + * - 중복 참여 방지 + * - 자동으로 이전 라운드들에 결석 처리 + */ + @Operation( + summary = "세션에 사용자 추가", + description = """ + ## 인증(JWT): **필요** + + """ + ) + @PostMapping("/{sessionId}/users") + public ResponseEntity addUserToSession( + @PathVariable UUID sessionId, + UUID userId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + SessionUserResponse response = sessionUserService.addUserToSession(sessionId,userId,userDetails.getUserId()); + + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 세션에서 사용자 제거 (관리자용) + * - 사용자를 세션에서 제거 + * - 해당 사용자의 모든 출석 기록도 함께 삭제 + */ + @Operation( + summary = "세션에서 사용자 제거", + description = """ + ## 인증(JWT): **필요** + + """ + ) + @DeleteMapping("/{sessionId}/users/{userId}") + public ResponseEntity removeUserFromSession( + @PathVariable UUID sessionId, + @PathVariable UUID userId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + log.info("세션에서 사용자 제거: 세션ID={}, 사용자ID={}", sessionId, userId); + + sessionUserService.removeUserFromSession(sessionId, userId, userDetails.getUserId()); + + log.info("세션에서 사용자 제거 완료: 세션ID={}, 사용자ID={}", sessionId, userId); + + return ResponseEntity.noContent().build(); + } + + /** + * 세션 참여자 조회 + * - 세션에 참여 중인 모든 사용자 목록 + * - 참여 순서대로 정렬 + */ + @Operation( + summary = "세션 참여자 조회", + description = """ + ## 인증(JWT): **필요** + + """ + ) + @GetMapping("/{sessionId}/users") + public ResponseEntity> getSessionUsers(@PathVariable UUID sessionId, @AuthenticationPrincipal CustomUserDetails userDetails) { + log.info("세션 참여자 조회: 세션ID={}", sessionId); + + List users = sessionUserService.getSessionUsers(sessionId, userDetails.getUserId()); + + log.info("세션 참여자 조회 완료: 세션ID={}, 참여자 수={}", sessionId, users.size()); + + return ResponseEntity.ok(users); + } + + +} 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 deleted file mode 100644 index 4a04a20c..00000000 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceCheckInRequest.java +++ /dev/null @@ -1,49 +0,0 @@ -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 { - - private UUID roundId; - - @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; - - @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; -} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRequest.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRequest.java deleted file mode 100644 index 83648720..00000000 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRequest.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.sejongisc.backend.attendance.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema( - title = "출석 체크인 요청", - description = "학생이 출석 체크인 시 제출하는 요청 객체. 출석 코드, GPS 위치 정보를 포함합니다." -) -public class AttendanceRequest { - - // === 출석 체크용 필드들 === - @Schema( - description = "출석 세션의 6자리 코드. 관리자가 생성한 코드를 입력합니다.", - example = "ABC123", - minLength = 6, - maxLength = 6 - ) - @NotBlank(message = "출석 코드는 필수입니다") - @Size(min = 6, max = 6, message = "출석 코드는 6자리여야 합니다") - private String code; - - @Schema( - description = "체크인 위치의 위도 (latitude). WGS84 좌표계 사용. 범위: -90 ~ 90", - example = "37.4979", - minimum = "-90.0", - maximum = "90.0" - ) - @NotNull(message = "위도는 필수입니다") - @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다") - @DecimalMax(value = "90.0", message = "위도는 90 이하이어야 합니다") - private Double latitude; - - @Schema( - description = "체크인 위치의 경도 (longitude). WGS84 좌표계 사용. 범위: -180 ~ 180", - example = "127.0276", - minimum = "-180.0", - maximum = "180.0" - ) - @NotNull(message = "경도는 필수입니다") - @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다") - @DecimalMax(value = "180.0", message = "경도는 180 이하이어야 합니다") - private Double longitude; - - @Schema( - description = "체크인 시 추가 메모. 선택 사항입니다.", - example = "교실 앞에서 체크인", - maxLength = 500 - ) - @Size(max = 500, message = "메모는 500자 이하여야 합니다") - private String note; - -} 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 23c1c26d..bdbbe94c 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 @@ -1,114 +1,35 @@ package org.sejongisc.backend.attendance.dto; -import com.fasterxml.jackson.annotation.JsonFormat; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.*; -import org.sejongisc.backend.attendance.entity.AttendanceStatus; - import java.time.LocalDateTime; import java.util.UUID; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema( - title = "출석 정보 응답", - description = "학생의 출석 기록 정보. 체크인 시간, 출석 상태, 포인트, GPS 정보 등을 포함합니다." -) -public class AttendanceResponse { - - @Schema( - description = "출석 기록의 고유 ID", - example = "550e8400-e29b-41d4-a716-446655440000" - ) - private UUID attendanceId; - - @Schema( - description = "체크인한 학생의 사용자 ID", - example = "f47ac10b-58cc-4372-a567-0e02b2c3d479" - ) - private UUID userId; - - @Schema( - description = "체크인한 학생의 이름", - example = "김철수" - ) - private String userName; - - @Schema( - description = "해당 출석 세션의 ID", - example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - ) - private UUID attendanceSessionId; - - @Schema( - description = "해당 출석 라운드의 ID", - example = "b5c3d4e5-f6a7-8901-bcde-f12345678901" - ) - private UUID attendanceRoundId; - - @Schema( - description = "출석 상태. PRESENT(출석), LATE(지각), ABSENT(결석), EXCUSED(사유결석)", - example = "PRESENT", - implementation = AttendanceStatus.class - ) - private AttendanceStatus attendanceStatus; - - @Schema( - description = "실제 체크인 시간 (ISO 8601 형식)", - example = "2024-10-31 14:30:15" - ) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime checkedAt; - - @Schema( - description = "체크인으로 인해 획득한 포인트", - example = "10" - ) - private Integer awardedPoints; - - @Schema( - description = "체크인 시 작성한 메모", - example = "교실 입구에서 체크인했습니다" - ) - private String note; - - @Schema( - description = "체크인 시의 위도", - example = "37.4979" - ) - private Double checkInLatitude; - - @Schema( - description = "체크인 시의 경도", - example = "127.0276" - ) - private Double checkInLongitude; - - @Schema( - description = "체크인에 사용한 디바이스 정보", - example = "iPhone 14 Pro" - ) - private String deviceInfo; - - @Schema( - description = "지각 여부. true면 지각(5분 이상 경과 후 체크인)", - example = "false" - ) - private boolean isLate; - - @Schema( - description = "출석 기록 생성 시간", - example = "2024-10-31 14:30:15" - ) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime createdAt; - - @Schema( - description = "출석 기록 최종 수정 시간", - example = "2024-10-31 15:00:00" - ) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime updatedAt; -} +import org.sejongisc.backend.attendance.entity.Attendance; + +public record AttendanceResponse( + UUID attendanceId, + UUID userId, + String userName, + UUID roundId, + String attendanceStatus, + LocalDateTime checkedAt, + String note, + Double checkInLatitude, + Double checkInLongitude, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static AttendanceResponse from(Attendance a) { + return new AttendanceResponse( + a.getAttendanceId(), + a.getUser() != null ? a.getUser().getUserId() : null, + a.getUser() != null ? a.getUser().getName() : "익명", + a.getAttendanceRound() != null ? a.getAttendanceRound().getRoundId() : null, + a.getAttendanceStatus() != null ? a.getAttendanceStatus().name() : null, + a.getCheckedAt(), + a.getNote(), + a.getCheckInLocation() != null ? a.getCheckInLocation().getLat() : null, + a.getCheckInLocation() != null ? a.getCheckInLocation().getLng() : null, + a.getCreatedDate(), + a.getUpdatedDate() + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundQrTokenResponse.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundQrTokenResponse.java new file mode 100644 index 00000000..00aaf38e --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundQrTokenResponse.java @@ -0,0 +1,9 @@ +package org.sejongisc.backend.attendance.dto; + +import java.util.UUID; + +public record AttendanceRoundQrTokenResponse( + UUID roundId, + String qrToken, + long expiresAtEpochSec +) {} 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 index 8ea59e6f..1c81586c 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundRequest.java @@ -1,64 +1,16 @@ 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; -import java.util.UUID; +import java.time.LocalDateTime; -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema( - title = "출석 라운드 생성/수정 요청", - description = "출석 라운드를 생성하거나 수정할 때 사용하는 요청 객체. " + - "라운드의 날짜, 시작 시간, 출석 가능한 시간을 설정합니다." -) -public class AttendanceRoundRequest { +public record AttendanceRoundRequest( + LocalDate roundDate, + LocalDateTime startAt, + LocalDateTime closeAt, // 선택(없으면 null로 받고 서버가 자동 계산해도 됨) + String roundName, + String locationName +) { - @NotNull(message = "세션 ID는 필수입니다") - @Schema( - description = "회차가 속할 세션의 ID", - example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - type = "string", - format = "uuid" - ) - private UUID sessionId; - @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 index a7c3f932..6be88545 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceRoundResponse.java @@ -1,78 +1,35 @@ 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 java.time.LocalDate; -import java.time.LocalTime; +import java.time.LocalDateTime; import java.util.UUID; +import org.sejongisc.backend.attendance.entity.AttendanceRound; -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema( - title = "출석 라운드 응답", - description = "출석 라운드의 상세 정보. 라운드 날짜, 시간, 상태를 포함합니다." -) -public class AttendanceRoundResponse { - - @Schema( - description = "라운드의 고유 ID", - example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - ) - private UUID id; - - @Schema( - description = "라운드 진행 날짜", - example = "2025-11-06", - type = "string", - format = "date" - ) - @JsonFormat(pattern = "yyyy-MM-dd") - private LocalDate date; - - @Schema( - description = "라운드 출석 시작 시간", - example = "10:00:00", - type = "string", - format = "time" - ) - @JsonFormat(pattern = "HH:mm:ss") - private LocalTime startTime; - - @Schema( - description = "출석 가능한 시간 (분단위)", - example = "20" - ) - private Integer availableMinutes; - - @Schema( - description = "라운드의 현재 상태. (upcoming, active, closed 등)", - example = "active" - ) - private String status; - - /** - * 엔티티를 DTO로 변환 - * status는 실시간으로 계산되어 반환됨 - */ - public static AttendanceRoundResponse fromEntity(AttendanceRound round) { - // 현재 시간 기준으로 라운드 상태를 실시간 계산 - // RoundStatus.getValue()를 사용하여 명시적이고 안전한 변환 - String statusString = round.calculateCurrentStatus().getValue(); - - return AttendanceRoundResponse.builder() - .id(round.getRoundId()) - .date(round.getRoundDate()) - .startTime(round.getStartTime()) - .availableMinutes(round.getAllowedMinutes()) - .status(statusString) - .build(); +public record AttendanceRoundResponse( + UUID roundId, + UUID sessionId, + LocalDate roundDate, + LocalDateTime startAt, + LocalDateTime closeAt, + String roundStatus, + String roundName, + String locationName +) { + public static AttendanceRoundResponse from(AttendanceRound round) { + return from(round, false); + } + public static AttendanceRoundResponse from(AttendanceRound round, boolean includeQr) { + return new AttendanceRoundResponse( + round.getRoundId(), + round.getAttendanceSession().getAttendanceSessionId(), + round.getRoundDate(), + round.getStartAt(), + round.getCloseAt(), + round.getRoundStatus().name(), + round.getRoundName(), + round.getLocationName() + ); } } + diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionRequest.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionRequest.java index ee753113..3f5ee8d1 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/AttendanceSessionRequest.java @@ -1,90 +1,26 @@ package org.sejongisc.backend.attendance.dto; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.*; -import lombok.*; - -import java.time.LocalTime; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema( - title = "출석 세션 생성/수정 요청", - description = "관리자가 출석 세션을 생성하거나 수정할 때 사용하는 요청 객체. " + - "세션의 기본 정보, 기본 시간, 위치, 포인트 설정을 포함합니다." -) -public class AttendanceSessionRequest { - - @Schema( - description = "세션의 제목/이름", - example = "2024년 10월 동아리 정기 모임", - maxLength = 100 - ) - @NotBlank(message = "제목은 필수입니다") - @Size(max = 100, message = "제목은 100자 이하여야 합니다") - private String title; - - @Schema( - description = "세션의 기본 시작 시간 (HH:mm:ss 형식). 시간 단위만 지정합니다.", - example = "18:30:00", - type = "string", - pattern = "HH:mm:ss" - ) - @NotNull(message = "기본 시작 시간은 필수입니다") - private LocalTime defaultStartTime; - - @Schema( - description = "출석 인정 시간 (분 단위). " + - "범위: 5분 ~ 240분(4시간)", - example = "30", - minimum = "5", - maximum = "240" - ) - @Min(value = 5, message = "최소 5분 이상이어야 합니다") - @Max(value = 240, message = "최대 4시간 설정 가능합니다") - private Integer allowedMinutes; - - @Schema( - description = "출석 완료 시 지급할 포인트", - example = "10", - minimum = "0" - ) - @Min(value = 0, message = "포인트는 0 이상이어야 합니다") - private Integer rewardPoints; - - @Schema( - description = "세션 개최 위치의 위도 (latitude). WGS84 좌표계. 선택 사항", - example = "37.4979", - minimum = "-90.0", - maximum = "90.0" - ) - @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다") - @DecimalMax(value = "90.0", message = "위도는 90 이하이어야 합니다") - private Double latitude; - - @Schema( - description = "세션 개최 위치의 경도 (longitude). WGS84 좌표계. 선택 사항", - example = "127.0276", - minimum = "-180.0", - maximum = "180.0" - ) - @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다") - @DecimalMax(value = "180.0", message = "경도는 180 이하이어야 합니다") - private Double longitude; +import org.sejongisc.backend.attendance.entity.AttendanceSession; + +/** + * 출석 세션 생성/수정 요청 DTO + */ + +public record AttendanceSessionRequest( + String title, + String description, + Integer allowedMinutes, + String status +) { + public static AttendanceSessionRequest from(AttendanceSession session) { + return new AttendanceSessionRequest( + session.getTitle(), + session.getDescription(), + session.getAllowedMinutes(), + session.getStatus().name() + ); + } +} - @Schema( - description = "GPS 기반 위치 검증을 위한 반경 (미터 단위). " + - "지정된 위치에서 이 반경 내에 있어야만 체크인이 가능합니다. " + - "범위: 1m ~ 500m", - example = "100", - minimum = "1", - maximum = "500" - ) - @Min(value = 1, message = "반경은 1m 이상이어야 합니다") - @Max(value = 500, message = "반경은 500m 이하여야 합니다") - private Integer radiusMeters; -} 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 417ea025..c79dc5dd 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 @@ -1,71 +1,50 @@ package org.sejongisc.backend.attendance.dto; -import com.fasterxml.jackson.annotation.JsonFormat; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.*; -import java.time.LocalTime; import java.util.UUID; +import org.sejongisc.backend.attendance.entity.AttendanceSession; +import org.sejongisc.backend.attendance.entity.SessionRole; + +public record AttendanceSessionResponse( + UUID sessionId, + AttendanceSessionRequest session, + SessionRole myRole, + Permissions permissions +) { + public record Permissions( + boolean canUpdateSession, + boolean canCloseSession, + boolean canAddManager + ) { + public static Permissions from(SessionRole role) { + if (role == null|| role == SessionRole.PARTICIPANT) { + return new Permissions(false, false, false); + }else if(role == SessionRole.MANAGER) { + return new Permissions(true, true, false); + } + boolean owner = role == SessionRole.OWNER; + boolean manager = role == SessionRole.MANAGER; + + return new Permissions( + owner || manager, + owner || manager, + owner + ); + } -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema( - title = "출석 세션 응답", - description = "출석 세션의 상세 정보. 세션 설정, 기본 시간, 위치 등을 포함합니다." -) -public class AttendanceSessionResponse { - - @Schema( - description = "출석 세션의 고유 ID", - example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - ) - private UUID attendanceSessionId; - - @Schema( - description = "세션의 제목/이름", - example = "금융 IT팀 세션" - ) - private String title; - - @Schema( - description = "세션 개최 위치 정보", - example = "{\"lat\": 37.5499, \"lng\": 127.0751}" - ) - private LocationInfo location; - - @Schema( - description = "세션의 기본 시작 시간", - example = "18:30:00" - ) - @JsonFormat(pattern = "HH:mm:ss") - private LocalTime defaultStartTime; - - @Schema( - description = "출석 인정 시간 (분 단위)", - example = "30" - ) - private Integer defaultAvailableMinutes; - - @Schema( - description = "출석 완료 시 지급할 포인트", - example = "100" - ) - private Integer rewardPoints; - - @Getter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class LocationInfo { - @Schema(description = "위도", example = "37.5499") - private Double lat; + } - @Schema(description = "경도", example = "127.0751") - private Double lng; + public static AttendanceSessionResponse from(AttendanceSession session, SessionRole myRole) { + return new AttendanceSessionResponse( + session.getAttendanceSessionId(), + AttendanceSessionRequest.from(session), + myRole, + Permissions.from(myRole) + ); + } + // 목록 조회에서 쓰기 좋은 기본 변환(비로그인/비멤버 기준) + public static AttendanceSessionResponse from(AttendanceSession session) { + return from(session, null); + } - @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/RoundAttendanceResponse.java b/backend/src/main/java/org/sejongisc/backend/attendance/dto/RoundAttendanceResponse.java deleted file mode 100644 index 9394da12..00000000 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/RoundAttendanceResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 2eae2366..00000000 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -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 index 73a2c666..edf250a3 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/dto/SessionUserResponse.java @@ -1,26 +1,29 @@ package org.sejongisc.backend.attendance.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.Builder; import java.time.LocalDateTime; import java.util.UUID; +import org.sejongisc.backend.attendance.entity.SessionRole; +import org.sejongisc.backend.attendance.entity.SessionUser; -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor -public class SessionUserResponse { - - private UUID sessionUserId; - - private UUID userId; - - private UUID sessionId; - - private String userName; - - private LocalDateTime createdAt; +public record SessionUserResponse( + UUID sessionUserId, + UUID sessionId, + UUID userId, + String userName, + SessionRole sessionRole, + LocalDateTime createdAt +) { + public static SessionUserResponse from(SessionUser su) { + return SessionUserResponse.builder() + .sessionUserId(su.getSessionUserId()) + .sessionId(su.getAttendanceSession().getAttendanceSessionId()) + .userId(su.getUser().getUserId()) + .userName(su.getUser().getName()) + .sessionRole(su.getSessionRole()) + .createdAt(su.getCreatedDate()) + .build(); + } } 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 9c138d7e..4ee37680 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 @@ -39,32 +39,34 @@ public class Attendance extends BasePostgresEntity { private AttendanceRound attendanceRound; @Enumerated(EnumType.STRING) - @lombok.Setter private AttendanceStatus attendanceStatus; @CreationTimestamp @Column(name = "checked_at") private LocalDateTime checkedAt; - // todo User의 point와 동기화 필요 - // 출석했을때 무조건 100포인트라면 굳이 필요 없을 듯 - // 이거 session에 정해져 있지 않나 여기에 없어도 될거 같은데 - @Column(name = "awarded_points") - @lombok.Setter - private Integer awardedPoints; // todo 지각 사유나 특이사항 적는칸-> 개인이 작성하면 관리자만 볼 수 있게 해야할거 같은디 @Column(length = 500) - @lombok.Setter private String note; @Embedded - @lombok.Setter private Location checkInLocation; // 지각 여부 계산 / 상태 업데이트 - + public void changeStatus(AttendanceStatus newStatus, String reason) { + if (newStatus == null) return; + this.attendanceStatus = newStatus; + + if (reason != null && !reason.isBlank()) { + this.note = reason; + } + } + + public void recordLocation(Location location) { + this.checkInLocation = location; + } } 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 index 49525a99..8e97263c 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceRound.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceRound.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonManagedReference; import jakarta.persistence.*; import java.time.Duration; +import java.time.LocalDateTime; import lombok.*; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; @@ -20,7 +21,6 @@ */ @Entity @Getter -@Setter @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor @@ -33,40 +33,59 @@ public class AttendanceRound extends BasePostgresEntity { @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) + private LocalDateTime startAt; // 시작 시간 미리 예약 - @Column(nullable = false) - private LocalTime endTime; // 종료 시간 (예: 10:20) + + private LocalDateTime closeAt; // 종료 시간 관리자가 설정 or 일정시간 경과시 자동 설정 @Enumerated(EnumType.STRING) @Column(nullable = false) - // todo 라운드 상태 관리 로직 필요 // 생성시 upcoming, 출석 시작시 active, 출석 종료시 closed private RoundStatus roundStatus; // UPCOMING, ACTIVE, CLOSED - @Column(name = "round_name", length = 255, nullable = true) + @Column(name = "round_name", length = 255, nullable = false) private String roundName; // 라운드 이름 (예: "1차 정기모임", "OT" 등) - @Column(nullable = false) private String locationName; // 장소 이름 (예: "세종대학교 310동") // todo 라운드별 관리자에게만 발급되는 큐알 코드는 필요할 거 같음 - private String qrCode; // 라운드별 출석 QR 코드 문자열 + @Column(name = "qr_secret", nullable = false, length = 120) + private String qrSecret; // 라운드별 참석 조회용 @OneToMany(mappedBy = "attendanceRound", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) @Builder.Default private List attendances = new ArrayList<>(); + /** + * 상태 변경 + */ + + public void changeStatus(RoundStatus newStatus) { + // 종료된 라운드는 상태 변경 불가 + if (this.roundStatus == RoundStatus.CLOSED) { + return; + } + + if(this.roundStatus == RoundStatus.ACTIVE &&newStatus == RoundStatus.UPCOMING) { + // ACTIVE -> UPCOMING 불가 + return; + } + + + if (this.roundStatus == newStatus) { + return; // 이미 그 상태이면 무시 (DB 쿼리 방지) + } + this.roundStatus = newStatus; + } 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 10a05aca..a1270885 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 @@ -23,23 +23,14 @@ public class AttendanceSession extends BasePostgresEntity { @Column(name = "attendance_session_id", columnDefinition = "uuid") private UUID attendanceSessionId; - - @Column(nullable = false) private String title; // "세투연 9/17" + private String description; @Column(name = "allowed_minutes", nullable = false) private Integer allowedMinutes; // 출석 인정 시간(분) - 예: 30분 - - // todo 세션별 포인트 다르게 줄거면 필요 아니면 굳이?, User의 point와 동기화 필요 - @Column(name = "reward_points") - private Integer rewardPoints; // 출석 시 지급할 포인트 - - @Embedded - private Location location; // 위치 기반 출석을 위한 GPS 좌표 - @Enumerated(EnumType.STRING) private SessionStatus status; @@ -52,4 +43,6 @@ public class AttendanceSession extends BasePostgresEntity { @Builder.Default private List sessionUsers = new ArrayList<>(); + + } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionRole.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionRole.java index 093d22c5..c04a5ab3 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionRole.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionRole.java @@ -7,6 +7,7 @@ */ @RequiredArgsConstructor public enum SessionRole { + OWNER("세션 소유자"), // 세션 생성자 MANAGER("관리자"), // 세션 관리자 PARTICIPANT("참가자"); // 세션 참가자 private final String description; diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionStatus.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionStatus.java index 46924842..35fda9e6 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionStatus.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionStatus.java @@ -4,7 +4,6 @@ @RequiredArgsConstructor public enum SessionStatus { - UPCOMING("예정"), // 아직 시작 전 OPEN("진행중"), // 체크인 가능한 상태 CLOSED("종료"); // 체크인 시간 마감 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 index 4207b8a9..1ea8e21c 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionUser.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionUser.java @@ -47,6 +47,13 @@ public class SessionUser extends BasePostgresEntity { @Enumerated(EnumType.STRING) private SessionRole sessionRole; + public void changeRole(SessionRole newRole) { + if (this.sessionRole == newRole) { + return; // 이미 그 역할이면 무시 (DB 쿼리 방지) + } + this.sessionRole = newRole; + } + /** * toString 오버라이드 (순환 참조 방지) 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 7cf20bc8..1a4b960a 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 @@ -1,6 +1,7 @@ package org.sejongisc.backend.attendance.repository; import org.sejongisc.backend.attendance.entity.Attendance; +import org.sejongisc.backend.attendance.entity.AttendanceRound; import org.sejongisc.backend.attendance.entity.AttendanceSession; import org.sejongisc.backend.attendance.entity.AttendanceStatus; import org.sejongisc.backend.user.entity.User; @@ -16,52 +17,25 @@ @Repository public interface AttendanceRepository extends JpaRepository { + boolean existsByUserAndAttendanceRound( User user, AttendanceRound round); - // 세션별 출석자 목록 조회 - List findByAttendanceSessionOrderByCheckedAtAsc(AttendanceSession attendanceSession); + boolean existsByUser_UserIdAndAttendanceRound_RoundId(UUID userId, UUID roundId); + List findByAttendanceRound_RoundId(UUID roundId); + void deleteAllByAttendanceRound_AttendanceSession_AttendanceSessionIdAndUser_UserId( + UUID sessionId, + UUID userId + ); - // 사용자별 출석 이력 - List findByUserOrderByCheckedAtDesc(User user); - - // 중복 출석 방지 - boolean existsByAttendanceSessionAndUser(AttendanceSession attendanceSession, User user); - - // 세션별 모든 출석 기록 조회 - List findByAttendanceSession(AttendanceSession attendanceSession); - - // 특정 사용자의 세션 출석 기록 조회 - Optional findByAttendanceSessionAndUser(AttendanceSession attendanceSession, User user); - // 세션과 사용자 ID로 출석 기록 조회 - Optional findByAttendanceSessionAndUser_UserId(AttendanceSession attendanceSession, UUID userId); - // 특정 기간의 출석 기록 조회 - @Query("SELECT a FROM Attendance a WHERE a.checkedAt BETWEEN :startDate AND :endDate") - List findByCheckedAtBetween(@Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - // 특정 상태의 출석 기록 조회 - List findByAttendanceStatus(AttendanceStatus attendanceStatus); - - // 세션별 출석 통계 - @Query("SELECT COUNT(a) FROM Attendance a WHERE a.attendanceSession = :session") - Long countByAttendanceSession(@Param("session") AttendanceSession session); + // 사용자별 출석 이력 + List findByUserOrderByCheckedAtDesc(User user); - // 세션별 상태별 출석 통계 - @Query("SELECT COUNT(a) FROM Attendance a WHERE a.attendanceSession = :session AND a.attendanceStatus = :status") - 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); - // 세션의 특정 사용자 모든 출석 기록 조회 (라운드 관계없이) - @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 5b9ec305..c2af36b7 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 @@ -1,7 +1,9 @@ package org.sejongisc.backend.attendance.repository; +import java.time.LocalDateTime; import org.sejongisc.backend.attendance.entity.AttendanceRound; 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; @@ -14,6 +16,36 @@ @Repository public interface AttendanceRoundRepository extends JpaRepository { + List findByAttendanceSession_AttendanceSessionIdAndRoundDateBefore(UUID sessionId, LocalDate date); + + + + // UPCOMING -> ACTIVE + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update AttendanceRound r + set r.roundStatus = 'ACTIVE' + where r.roundStatus = 'UPCOMING' + and r.startAt <= :now + and r.closeAt > :now + """) + int activateDueRounds(LocalDateTime now); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update AttendanceRound r + set r.roundStatus = 'CLOSED' + where r.roundStatus <> 'CLOSED' + and r.closeAt <= :now +""") + int closeDueRounds(LocalDateTime now); + + + + + Optional findByQrSecret(String qrCode); + List findByAttendanceSession_AttendanceSessionId(UUID sessionId); + /** * 세션 ID로 해당 세션의 모든 라운드 조회 */ @@ -36,15 +68,6 @@ public interface AttendanceRoundRepository extends JpaRepository findNthRoundInSession(@Param("sessionId") UUID sessionId, @Param("offset") int offset); /** * 세션의 특정 날짜 이전의 모든 라운드 조회 diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceSessionRepository.java b/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceSessionRepository.java index b37dda89..e9c48379 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceSessionRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/repository/AttendanceSessionRepository.java @@ -12,12 +12,8 @@ @Repository public interface AttendanceSessionRepository extends JpaRepository { - // 출석 코드로 세션 찾기 (학생 출석 체크) - Optional findByCode(String code); - // 상태별 세션 조회 List findByStatus(SessionStatus status); - // 코드 중복 체크 - boolean existsByCode(String code); + } 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 index a43eb33c..be5b3689 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/repository/SessionUserRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/repository/SessionUserRepository.java @@ -14,48 +14,26 @@ @Repository public interface SessionUserRepository extends JpaRepository { + Optional findByAttendanceSession_AttendanceSessionIdAndUser_UserId(UUID sessionId, UUID userId); + + + boolean existsByAttendanceSession_AttendanceSessionIdAndUser_UserId(UUID sessionId, UUID userId); + + void deleteByAttendanceSession_AttendanceSessionIdAndUser_UserId(UUID sessionId, UUID userId); + + List findByAttendanceSession_AttendanceSessionId(UUID sessionId); - /** - * 특정 세션의 모든 참여자 조회 - */ - @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 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); /** * 세션의 참여자 수 */ - @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/AttendanceAuthorizationService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceAuthorizationService.java new file mode 100644 index 00000000..7a28f49a --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceAuthorizationService.java @@ -0,0 +1,57 @@ +package org.sejongisc.backend.attendance.service; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.attendance.entity.SessionRole; +import org.sejongisc.backend.attendance.entity.SessionUser; +import org.sejongisc.backend.attendance.repository.AttendanceRoundRepository; +import org.sejongisc.backend.attendance.repository.SessionUserRepository; +import org.springframework.stereotype.Service; + +/** + * 출석 권한 관리 서비스 + */ +@Service +@RequiredArgsConstructor +public class AttendanceAuthorizationService { + private final SessionUserRepository sessionUserRepository; + + public void ensureAuthenticated(UUID userId) { + if (userId == null) throw new IllegalStateException("UNAUTHENTICATED"); + } + + public SessionRole getSessionRole(UUID sessionId, UUID userId) { + if (userId == null) return null; // 조회용: 비로그인은 null role + return sessionUserRepository + .findByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, userId) + .map(SessionUser::getSessionRole) + .orElse(null); + } + + public SessionRole requireRole(UUID sessionId, UUID userId) { + ensureAuthenticated(userId); + return sessionUserRepository + .findByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, userId) + .map(SessionUser::getSessionRole) + .orElseThrow(() -> new IllegalStateException("NOT_SESSION_MEMBER")); + } + + public void ensureMember(UUID sessionId, UUID userId) { + requireRole(sessionId, userId); // 여기서 로그인+멤버 체크 끝 + } + + public void ensureAdmin(UUID sessionId, UUID userId) { + SessionRole role = requireRole(sessionId, userId); + if (role != SessionRole.MANAGER && role != SessionRole.OWNER) { + throw new IllegalStateException("NOT_SESSION_ADMIN"); + } + } + + public void ensureOwner(UUID sessionId, UUID userId) { + SessionRole role = requireRole(sessionId, userId); + if (role != SessionRole.OWNER) { + throw new IllegalStateException("NOT_SESSION_OWNER"); + } + } +} + 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 index 63d19556..768bf9ef 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java @@ -1,158 +1,188 @@ package org.sejongisc.backend.attendance.service; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.attendance.dto.AttendanceRoundQrTokenResponse; import org.sejongisc.backend.attendance.dto.AttendanceRoundRequest; import org.sejongisc.backend.attendance.dto.AttendanceRoundResponse; 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.attendance.util.QrTokenUtil; +import org.springframework.scheduling.annotation.Scheduled; 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; - private final SessionUserRepository sessionUserRepository; - private final AttendanceRepository attendanceRepository; - - - /** - * 라운드 생성 - */ - 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) - .roundStatus(RoundStatus.UPCOMING) - .build(); - - - - AttendanceRound saved = attendanceRoundRepository.save(round); - session.getRounds().add(saved); - // 양방향 관계를 DB에 반영하기 위해 세션도 저장 - attendanceSessionRepository.save(session); - - // ⭐ 라운드 생성 시 세션의 모든 SessionUser에 대해 PENDING 상태의 Attendance 미리 생성 - log.info("📝 세션 사용자에 대한 PENDING 출석 기록 생성 시작: sessionId={}, roundId={}", - sessionId, saved.getRoundId()); - - List sessionUsers = sessionUserRepository.findBySessionId(sessionId); - for (SessionUser sessionUser : sessionUsers) { - Attendance pendingAttendance = Attendance.builder() - .user(sessionUser.getUser()) - .attendanceRound(saved) - .attendanceStatus(AttendanceStatus.PENDING) - .build(); - attendanceRepository.save(pendingAttendance); - log.info(" ✓ PENDING 출석 기록 생성: userId={}, userName={}, roundId={}", - sessionUser.getUser().getUserId(), sessionUser.getUser().getName(), saved.getRoundId()); - } - - log.info("✅ 라운드 생성 완료 - sessionId: {}, roundId: {}, roundDate: {}, roundStatus: {}, 생성된PENDING개수: {}", - sessionId, saved.getRoundId(), saved.getRoundDate(), saved.getRoundStatus(), sessionUsers.size()); - return AttendanceRoundResponse.fromEntity(saved); - } catch (Exception e) { - log.error("❌ 라운드 생성 중 오류 발생: sessionId={}, error={}", sessionId, e.getMessage(), e); - throw new RuntimeException("라운드 생성에 실패했습니다: " + e.getMessage(), e); - } - } + private static final int DEFAULT_ROUND_DURATION_HOURS = 3; + private static final long QR_TOKEN_TTL_SECONDS = 90; // 60~120 추천 + + private final AttendanceRoundRepository attendanceRoundRepository; + private final AttendanceSessionRepository attendanceSessionRepository; + private final AttendanceAuthorizationService authorizationService; + + /** 라운드 생성(관리자/소유자) */ + + public AttendanceRoundResponse createRound(UUID sessionId, UUID userId, AttendanceRoundRequest req) { + authorizationService.ensureAdmin(sessionId, userId); + + AttendanceSession session = attendanceSessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("SESSION_NOT_FOUND")); + + validateCreateRequest(req); + + LocalDateTime closeAt = (req.closeAt() != null) + ? req.closeAt() + : req.startAt().plusHours(DEFAULT_ROUND_DURATION_HOURS); + + AttendanceRound round = AttendanceRound.builder() + .attendanceSession(session) + .roundStatus(RoundStatus.UPCOMING) + .roundDate(req.roundDate()) + .startAt(req.startAt()) + .closeAt(closeAt) + .roundName(req.roundName()) + .locationName(req.locationName()) + .qrSecret(QrTokenUtil.generateSecret()) + .build(); + + AttendanceRound saved = attendanceRoundRepository.save(round); + + // 목록/상세 응답에는 토큰을 넣지 않는 걸 추천(짧게 만료되므로) + return AttendanceRoundResponse.from(saved, false); + } + + /** 라운드 개별 조회(세션 멤버만) - 토큰은 별도 API로 발급 */ + @Transactional(readOnly = true) + public AttendanceRoundResponse getRound(UUID roundId, UUID userId) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("ROUND_NOT_FOUND")); + + UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); + authorizationService.ensureMember(sessionId, userId); - /** - * 라운드 조회 (개별) - */ - @Transactional(readOnly = true) - public AttendanceRoundResponse getRound(UUID roundId) { - AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) - .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + roundId)); + return AttendanceRoundResponse.from(round, false); + } - return AttendanceRoundResponse.fromEntity(round); + /** 세션 내 라운드 목록 조회(세션 멤버만) */ + @Transactional(readOnly = true) + public List getRoundsBySession(UUID sessionId, UUID userId) { + authorizationService.ensureMember(sessionId, userId); + + List rounds = attendanceRoundRepository + .findByAttendanceSession_AttendanceSessionIdOrderByRoundDateAsc(sessionId); + + return rounds.stream() + .map(r -> AttendanceRoundResponse.from(r, false)) + .toList(); + } + + /** 관리자만: QR 토큰 발급(짧게 유효) */ + @Transactional(readOnly = true) + public AttendanceRoundQrTokenResponse issueQrToken(UUID roundId, UUID userId) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("ROUND_NOT_FOUND")); + + UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); + authorizationService.ensureAdmin(sessionId, userId); + + // 라운드가 ACTIVE일 때만 발급하고 싶으면 아래 체크 추가: + if (round.getRoundStatus() != RoundStatus.ACTIVE) { + throw new IllegalStateException("ROUND_NOT_ACTIVE"); } - /** - * 세션 내 라운드 목록 조회 - */ - @Transactional(readOnly = true) - public List getRoundsBySession(UUID sessionId) { - List rounds = attendanceRoundRepository - .findByAttendanceSession_AttendanceSessionIdOrderByRoundDateAsc(sessionId); - - return rounds.stream() - .map(AttendanceRoundResponse::fromEntity) - .collect(Collectors.toList()); + QrTokenUtil.IssuedToken issued = QrTokenUtil.issue(round.getRoundId(), round.getQrSecret(), QR_TOKEN_TTL_SECONDS); + return new AttendanceRoundQrTokenResponse(round.getRoundId(), issued.token(), issued.expiresAtEpochSec()); + } + + /** 참가자 출석 처리 쪽에서 사용: 토큰 검증 후 라운드 조회 */ + @Transactional(readOnly = true) + public AttendanceRound verifyQrTokenAndGetRound(String qrToken) { + // 토큰 파싱을 위해 roundId 먼저 뽑고 → 라운드 가져온 뒤 secret으로 검증 + String[] parts = qrToken.split(":"); + if (parts.length != 3) throw new IllegalStateException("QR_TOKEN_MALFORMED"); + + UUID roundId; + try { + roundId = UUID.fromString(parts[0]); + } catch (Exception e) { + throw new IllegalStateException("QR_TOKEN_MALFORMED"); } - /** - * 라운드 정보 수정 - */ - public AttendanceRoundResponse updateRound(UUID roundId, AttendanceRoundRequest request) { - AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) - .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + roundId)); - - - AttendanceRound updated = attendanceRoundRepository.save(round); - log.info("라운드 수정 완료 - roundId: {}", roundId); - return AttendanceRoundResponse.fromEntity(updated); + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalStateException("ROUND_NOT_FOUND")); + + // 라운드 상태 체크(선택이 아니라 사실상 필수) + if (round.getRoundStatus() != RoundStatus.ACTIVE) { + throw new IllegalStateException("ROUND_NOT_ACTIVE"); } - /** - * 라운드 삭제 - */ - public void deleteRound(UUID roundId) { - AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) - .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + roundId)); + // 서명/만료 검증 + QrTokenUtil.verifyAndParse(qrToken, round.getQrSecret()); + return round; + } - AttendanceSession session = round.getAttendanceSession(); - session.getRounds().remove(round); + /** 라운드 삭제(관리자/소유자) */ + public void deleteRound(UUID roundId, UUID userId) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("ROUND_NOT_FOUND")); - attendanceRoundRepository.delete(round); - log.info("라운드 삭제 완료 - roundId: {}", roundId); - } + UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); + authorizationService.ensureAdmin(sessionId, userId); + + attendanceRoundRepository.delete(round); + log.info("라운드 삭제 완료 - roundId: {}", roundId); + } + + /** 라운드 마감(관리자/소유자) */ + public void closeRound(UUID roundId, UUID userId) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("ROUND_NOT_FOUND")); - /** - * 특정 날짜의 라운드 조회 - */ - @Transactional(readOnly = true) - public AttendanceRoundResponse getRoundByDate(UUID sessionId, LocalDate date) { - AttendanceRound round = attendanceRoundRepository - .findByAttendanceSession_AttendanceSessionIdAndRoundDate(sessionId, date) - .orElseThrow(() -> new IllegalArgumentException("해당 날짜의 라운드를 찾을 수 없습니다")); + UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); + authorizationService.ensureAdmin(sessionId, userId); - return AttendanceRoundResponse.fromEntity(round); + round.changeStatus(RoundStatus.CLOSED); + } + + /** 라운드 활성화(관리자/소유자) */ + public void openRound(UUID roundId, UUID userId) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("ROUND_NOT_FOUND")); + + UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); + authorizationService.ensureAdmin(sessionId, userId); + + round.changeStatus(RoundStatus.ACTIVE); + } + + + + @Scheduled(fixedRate = 10_000) + public void autoActivateAndCloseRounds() { + LocalDateTime now = LocalDateTime.now(); + int closed = attendanceRoundRepository.closeDueRounds(now); + int activated = attendanceRoundRepository.activateDueRounds(now); + + if (activated > 0 || closed > 0) { + log.info("activated={}, closed={}", activated, closed); + } + } + + private void validateCreateRequest(AttendanceRoundRequest req) { + if (req.roundDate() == null) throw new IllegalArgumentException("ROUND_DATE_REQUIRED"); + if (req.startAt() == null) throw new IllegalArgumentException("START_AT_REQUIRED"); + if (req.roundName() == null || req.roundName().isBlank()) throw new IllegalArgumentException("ROUND_NAME_REQUIRED"); + if (req.closeAt() != null && !req.closeAt().isAfter(req.startAt())) { + throw new IllegalArgumentException("END_AT_MUST_BE_AFTER_START_AT"); } + } } 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 d42d02f9..5718bbfd 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 @@ -1,25 +1,29 @@ package org.sejongisc.backend.attendance.service; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; 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.AttendanceResponse; -import org.sejongisc.backend.attendance.entity.*; +import org.sejongisc.backend.attendance.entity.Attendance; +import org.sejongisc.backend.attendance.entity.AttendanceRound; +import org.sejongisc.backend.attendance.entity.AttendanceSession; +import org.sejongisc.backend.attendance.entity.AttendanceStatus; +import org.sejongisc.backend.attendance.entity.RoundStatus; 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.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -28,325 +32,284 @@ public class AttendanceService { private final AttendanceRepository attendanceRepository; - private final AttendanceSessionRepository attendanceSessionRepository; private final AttendanceRoundRepository attendanceRoundRepository; private final UserRepository userRepository; + private final AttendanceAuthorizationService authorizationService; + private final AttendanceRoundService attendanceRoundService; + + /** - * 라운드 기반 출석 체크인 처리 - * - 특정 라운드의 시간 및 위치 검증 + * QR 토큰 기반 출석 체크인 처리(세션 멤버용) + * - qrToken으로 라운드 검증/조회 (HMAC + 만료 + ACTIVE) + * - 세션 멤버십 및 중복 출석 방지 * - 지각 판별 및 출석 상태 결정 */ - public AttendanceCheckInResponse checkInByRound(AttendanceCheckInRequest request, UUID userId) { - // 사용자 조회 - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); + public void checkIn(UUID userId, String qrToken) { - AttendanceRound round = attendanceRoundRepository.findRoundById(request.getRoundId()) - .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + request.getRoundId())); + // 토큰 검증 + ACTIVE 라운드 조회 + AttendanceRound round = attendanceRoundService.verifyQrTokenAndGetRound(qrToken); - AttendanceSession session = round.getAttendanceSession(); + // 세션 멤버 체크 + UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); + authorizationService.ensureMember(sessionId, userId); - log.info("라운드 출석 체크인 시작: 사용자={}, 라운드ID={}, 날짜={}", - user.getName(), request.getRoundId(), round.getRoundDate()); + User userRef = userRepository.getReferenceById(userId); - // 라운드 시간 검증 - 통일된 로직 - LocalDateTime now = LocalDateTime.now(); - LocalDate checkDate = now.toLocalDate(); - LocalTime checkTime = now.toLocalTime(); - LocalTime startTime = round.getStartTime(); - LocalTime endTime = round.getEndTime(); - LocalTime lateThreshold = startTime.plusMinutes(5); - - // 날짜 검증 - if (!checkDate.equals(round.getRoundDate())) { - log.warn("❌ 출석 날짜 불일치: 라운드ID={}, 사용자={}, 현재시간={}, 라운드날짜={}", - request.getRoundId(), user.getName(), now, round.getRoundDate()); - return AttendanceCheckInResponse.builder() - .roundId(request.getRoundId()) - .success(false) - .failureReason("출석 날짜가 맞지 않습니다") - .build(); + // 중복 출석 방지 + if (attendanceRepository.existsByUserAndAttendanceRound(userRef, round)) { + throw new IllegalStateException("ALREADY_CHECKED_IN"); } - // 시간 범위 검증: startTime <= now < endTime - boolean isWithinTimeWindow = !checkTime.isBefore(startTime) && checkTime.isBefore(endTime); - if (!isWithinTimeWindow) { - log.warn("❌ 출석 시간 초과: 라운드ID={}, 사용자={}, 현재시간={}, 시작={}, 종료={}", - request.getRoundId(), user.getName(), now, startTime, endTime); - return AttendanceCheckInResponse.builder() - .roundId(request.getRoundId()) - .success(false) - .failureReason("출석 시간 초과") - .build(); - } + LocalDateTime now = LocalDateTime.now(); - log.info("✅ 시간 검증 성공: 라운드ID={}, 사용자={}, 현재시간={}, 범위=[{}~{}]", - request.getRoundId(), user.getName(), now, startTime, endTime); - - // 2. 기존 출석 기록 확인 (PENDING 제외하고 실제 체크인한 기록만 중복으로 취급) - Attendance existingAttendance = attendanceRepository.findByAttendanceRound_RoundIdAndUser(request.getRoundId(), user) - .orElse(null); - if (existingAttendance != null && existingAttendance.getAttendanceStatus() != AttendanceStatus.PENDING) { - log.warn("중복 출석 시도: 라운드ID={}, 사용자={}, 기존상태={}", - request.getRoundId(), user.getName(), existingAttendance.getAttendanceStatus()); - return AttendanceCheckInResponse.builder() - .roundId(request.getRoundId()) - .success(false) - .failureReason("이미 출석 체크인하셨습니다") - .build(); - } + Attendance att = Attendance.builder() + .user(userRef) + .attendanceRound(round) + .attendanceStatus(decideLate(round, now) ? AttendanceStatus.LATE : AttendanceStatus.PRESENT) + .checkedAt(now) + .build(); - // 3. 위치 검증 (세션에 위치 정보가 있는 경우) - Location userLocation = null; - if (session.getLocation() != null) { - if (request.getLatitude() == null || request.getLongitude() == null) { - log.warn("위치 정보 누락: 라운드ID={}, 사용자={}", request.getRoundId(), user.getName()); - 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(), user.getName()); - return AttendanceCheckInResponse.builder() - .roundId(request.getRoundId()) - .success(false) - .failureReason("위치 불일치 - 허용 범위를 벗어났습니다") - .build(); - } + try { + attendanceRepository.save(att); + } catch (DataIntegrityViolationException e) { + throw new IllegalStateException("ALREADY_CHECKED_IN"); } + } - // 4. 출석 상태 판별 (정상/지각) - // 지각 기준: 시작시간 + 5분 이후면 LATE - AttendanceStatus status = checkTime.isAfter(lateThreshold) ? - AttendanceStatus.LATE : AttendanceStatus.PRESENT; - - log.info("📊 출석 상태 판별: 현재시간={}, 시작={}, 지각기준={}, 판별상태={}", - checkTime, startTime, lateThreshold, status); + /** + * 라운드별 출석 목록 조회 (관리자/OWNER) + */ + @Transactional(readOnly = true) + public List getAttendancesByRound(UUID roundId, UUID requesterUserId) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("ROUND_NOT_FOUND")); - // 5. 출석 기록 저장 - Attendance attendance = Attendance.builder() - .user(user) - .attendanceRound(round) - .attendanceStatus(status) - .checkedAt(java.time.LocalDateTime.now()) - .awardedPoints(session.getRewardPoints()) - .checkInLocation(userLocation) - .build(); + UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); + authorizationService.ensureAdmin(sessionId, requesterUserId); - log.info("💾 Attendance 객체 생성 완료: 사용자={}, 라운드ID={}, 상태={}, 체크인시간={}", - user.getName(), request.getRoundId(), status, attendance.getCheckedAt()); + return attendanceRepository.findByAttendanceRound_RoundId(roundId) + .stream() + .map(AttendanceResponse::from) + .toList(); + } - attendance = attendanceRepository.save(attendance); + /** + * 라운드 기반 출석 상태 수정 (관리자/OWNER) + * - roundId, targetUserId, status, reason + * - 기존 기록 없으면 새로 생성(예: 결석 처리) + */ + public AttendanceResponse updateAttendanceStatusByRound( + UUID adminUserId, + UUID roundId, + UUID targetUserId, + String status, + String reason + ) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new IllegalArgumentException("ROUND_NOT_FOUND")); - log.info("✅ Attendance 저장 완료: attendanceId={}, 사용자={}, 라운드ID={}, 상태={}", - attendance.getAttendanceId(), user.getName(), request.getRoundId(), status); + UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); + authorizationService.ensureAdmin(sessionId, adminUserId); - round.getAttendances().add(attendance); + User targetUser = userRepository.findById(targetUserId) + .orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND")); - log.info("✅ 라운드 출석 체크인 완료: 사용자={}, 상태={}, 저장된ID={}", user.getName(), status, attendance.getAttendanceId()); + AttendanceStatus newStatus = parseStatus(status); - long remainingSeconds = java.time.Duration.between( - checkTime, - endTime - ).getSeconds(); + Attendance attendance = attendanceRepository.findByAttendanceRound_RoundIdAndUser(roundId, targetUser) + .orElse(null); - return AttendanceCheckInResponse.builder() - .roundId(request.getRoundId()) - .success(true) - .status(status.toString()) - .checkedAt(attendance.getCheckedAt()) - .awardedPoints(attendance.getAwardedPoints()) - .remainingSeconds(Math.max(0, remainingSeconds)) + if (attendance == null) { + attendance = Attendance.builder() + .user(targetUser) + .attendanceRound(round) + .attendanceStatus(newStatus) + .note(reason) + .checkedAt(LocalDateTime.now()) // checkedAt을 수동으로 넣고 싶으면 @CreationTimestamp 제거 권장 .build(); + } else { + attendance.changeStatus(newStatus, reason); // ✅ 엔티티 메서드로 변경 + } + return AttendanceResponse.from(attendanceRepository.save(attendance)); } - /** - * 세션별 출석 목록 조회 - * - 관리자가 특정 세션의 모든 출석자 확인 - * - 출석 시간 순으로 정렬 - */ - @Transactional(readOnly = true) - public List getAttendancesBySession(UUID sessionId) { - AttendanceSession session = attendanceSessionRepository.findById(sessionId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); - - List attendances = attendanceRepository.findByAttendanceSessionOrderByCheckedAtAsc(session); - return attendances.stream() - .map(this::convertToResponse) - .collect(Collectors.toList()); - } - /** - * 사용자별 출석 이력 조회 - * - 개인의 모든 출석 기록 조회 - * - 최신 순으로 정렬 - */ @Transactional(readOnly = true) public List getAttendancesByUser(UUID userId) { User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다: " + userId)); + .orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND")); + List attendances = attendanceRepository.findByUserOrderByCheckedAtDesc(user); return attendances.stream() - .map(this::convertToResponse) - .collect(Collectors.toList()); + .map(AttendanceResponse::from) + .collect(Collectors.toList()); } - /** - * 출석 상태 수정(관리자용) - * - PRESENT/LATE/ABSENT 등으로 상태 변경 - * - 수정 사유 기록 및 로그 남기기 - */ - public AttendanceResponse updateAttendanceStatus(UUID sessionId, UUID memberId, String status, String reason, UUID adminId) { - User adminUser = userRepository.findById(adminId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 관리자입니다: " + adminId)); - log.info("출석 상태 수정 시작: 세션ID={}, 멤버ID={}, 새로운상태={}, 관리자={}", sessionId, memberId, status, adminUser.getName()); - - // 세션 존재 확인 - AttendanceSession session = attendanceSessionRepository.findById(sessionId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); - - // 해당 세션에서 해당 멤버의 출석 기록 찾기 - Attendance attendance = attendanceRepository.findByAttendanceSessionAndUser_UserId(session, memberId) - .orElseThrow(() -> new IllegalArgumentException("해당 세션에서 멤버의 출석 기록을 찾을 수 없습니다: " + memberId)); + // ----------------- helpers ----------------- - AttendanceStatus newStatus; + private AttendanceStatus parseStatus(String status) { + if (status == null || status.isBlank()) throw new IllegalArgumentException("STATUS_REQUIRED"); try { - newStatus = AttendanceStatus.valueOf(status.toUpperCase()); + return AttendanceStatus.valueOf(status.trim().toUpperCase()); } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("잘못된 출석 상태입니다: " + status); + throw new IllegalArgumentException("INVALID_ATTENDANCE_STATUS"); } - - attendance = attendanceRepository.save(attendance); - - log.info("출석 상태 수정 완료: 세션ID={}, 멤버ID={}, 상태={}", sessionId, memberId, newStatus); - - 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()); + private boolean decideLate(AttendanceRound round, LocalDateTime checkedAt) { + var threshold = round.getStartAt().plusMinutes(5); + return checkedAt.isAfter(threshold); } - /** - * 라운드 기반 출석 상태 수정 (관리자용) - * - 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) - .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 = attendanceRepository.save(attendance); - log.info("✅ Attendance 상태 업데이트 완료: status={}", newStatus); - } - - log.info("✅ 라운드 기반 출석 상태 수정 완료: roundId={}, userId={}, status={}", - roundId, userId, newStatus); - return convertToResponse(attendance); - } - /** - * Attendance 엔티티를 AttendanceResponse DTO로 변환 - * - 엔티티의 모든 필드를 Response 형태로 매핑 - * - 사용자 이름, 위치 정보, 지각 여부 포함 - */ - 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() : "익명") - .attendanceRoundId(attendance.getAttendanceRound() != null ? - attendance.getAttendanceRound().getRoundId() : null) - .attendanceStatus(attendance.getAttendanceStatus()) - .checkedAt(attendance.getCheckedAt()) - .awardedPoints(attendance.getAwardedPoints()) - .note(attendance.getNote()) - .checkInLatitude(attendance.getCheckInLocation() != null ? - attendance.getCheckInLocation().getLat() : null) - .checkInLongitude(attendance.getCheckInLocation() != null ? - attendance.getCheckInLocation().getLng() : null) - .createdAt(attendance.getCreatedDate()) - .updatedAt(attendance.getUpdatedDate()) - .build(); - } + // /** +// * 라운드 기반 출석 체크인 처리 +// * - 특정 라운드의 시간 및 위치 검증 +// * - 지각 판별 및 출석 상태 결정 +// */ +// public AttendanceCheckInResponse checkInByRound(AttendanceCheckInRequest request, UUID userId) { +// // 사용자 조회 +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); +// +// AttendanceRound round = attendanceRoundRepository.findRoundById(request.getRoundId()) +// .orElseThrow(() -> new IllegalArgumentException("라운드를 찾을 수 없습니다: " + request.getRoundId())); +// +// AttendanceSession session = round.getAttendanceSession(); +// +// log.info("라운드 출석 체크인 시작: 사용자={}, 라운드ID={}, 날짜={}", +// user.getName(), request.getRoundId(), round.getRoundDate()); +// +// // 라운드 시간 검증 - 통일된 로직 +// LocalDateTime now = LocalDateTime.now(); +// LocalDate checkDate = now.toLocalDate(); +// LocalTime checkTime = now.toLocalTime(); +// LocalTime startTime = round.getStartTime(); +// LocalTime endTime = round.getEndTime(); +// LocalTime lateThreshold = startTime.plusMinutes(5); +// +// // 날짜 검증 +// if (!checkDate.equals(round.getRoundDate())) { +// log.warn("❌ 출석 날짜 불일치: 라운드ID={}, 사용자={}, 현재시간={}, 라운드날짜={}", +// request.getRoundId(), user.getName(), now, round.getRoundDate()); +// return AttendanceCheckInResponse.builder() +// .roundId(request.getRoundId()) +// .success(false) +// .failureReason("출석 날짜가 맞지 않습니다") +// .build(); +// } +// +// // 시간 범위 검증: startTime <= now < endTime +// boolean isWithinTimeWindow = !checkTime.isBefore(startTime) && checkTime.isBefore(endTime); +// if (!isWithinTimeWindow) { +// log.warn("❌ 출석 시간 초과: 라운드ID={}, 사용자={}, 현재시간={}, 시작={}, 종료={}", +// request.getRoundId(), user.getName(), now, startTime, endTime); +// return AttendanceCheckInResponse.builder() +// .roundId(request.getRoundId()) +// .success(false) +// .failureReason("출석 시간 초과") +// .build(); +// } +// +// log.info("✅ 시간 검증 성공: 라운드ID={}, 사용자={}, 현재시간={}, 범위=[{}~{}]", +// request.getRoundId(), user.getName(), now, startTime, endTime); +// +// // 2. 기존 출석 기록 확인 (PENDING 제외하고 실제 체크인한 기록만 중복으로 취급) +// Attendance existingAttendance = attendanceRepository.findByAttendanceRound_RoundIdAndUser(request.getRoundId(), user) +// .orElse(null); +// if (existingAttendance != null && existingAttendance.getAttendanceStatus() != AttendanceStatus.PENDING) { +// log.warn("중복 출석 시도: 라운드ID={}, 사용자={}, 기존상태={}", +// request.getRoundId(), user.getName(), existingAttendance.getAttendanceStatus()); +// 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(), user.getName()); +// 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(), user.getName()); +// return AttendanceCheckInResponse.builder() +// .roundId(request.getRoundId()) +// .success(false) +// .failureReason("위치 불일치 - 허용 범위를 벗어났습니다") +// .build(); +// } +// } +// +// // 4. 출석 상태 판별 (정상/지각) +// // 지각 기준: 시작시간 + 5분 이후면 LATE +// AttendanceStatus status = checkTime.isAfter(lateThreshold) ? +// AttendanceStatus.LATE : AttendanceStatus.PRESENT; +// +// log.info("📊 출석 상태 판별: 현재시간={}, 시작={}, 지각기준={}, 판별상태={}", +// checkTime, startTime, lateThreshold, status); +// +// // 5. 출석 기록 저장 +// Attendance attendance = Attendance.builder() +// .user(user) +// .attendanceRound(round) +// .attendanceStatus(status) +// .checkedAt(java.time.LocalDateTime.now()) +// .awardedPoints(session.getRewardPoints()) +// .checkInLocation(userLocation) +// .build(); +// +// log.info("💾 Attendance 객체 생성 완료: 사용자={}, 라운드ID={}, 상태={}, 체크인시간={}", +// user.getName(), request.getRoundId(), status, attendance.getCheckedAt()); +// +// attendance = attendanceRepository.save(attendance); +// +// log.info("✅ Attendance 저장 완료: attendanceId={}, 사용자={}, 라운드ID={}, 상태={}", +// attendance.getAttendanceId(), user.getName(), request.getRoundId(), status); +// +// round.getAttendances().add(attendance); +// +// log.info("✅ 라운드 출석 체크인 완료: 사용자={}, 상태={}, 저장된ID={}", user.getName(), status, attendance.getAttendanceId()); +// +// long remainingSeconds = java.time.Duration.between( +// checkTime, +// endTime +// ).getSeconds(); +// +// return AttendanceCheckInResponse.builder() +// .roundId(request.getRoundId()) +// .success(true) +// .status(status.toString()) +// .checkedAt(attendance.getCheckedAt()) +// .awardedPoints(attendance.getAwardedPoints()) +// .remainingSeconds(Math.max(0, remainingSeconds)) +// .build(); +// } } 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 c3caa522..662cbb17 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,18 +4,21 @@ 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.SessionRole; import org.sejongisc.backend.attendance.entity.SessionStatus; -import org.sejongisc.backend.attendance.repository.AttendanceRepository; +import org.sejongisc.backend.attendance.entity.SessionUser; import org.sejongisc.backend.attendance.repository.AttendanceSessionRepository; +import org.sejongisc.backend.attendance.repository.SessionUserRepository; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +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.util.List; import java.util.UUID; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -23,81 +26,70 @@ @Slf4j public class AttendanceSessionService { - private final AttendanceRepository attendanceRepository; private final AttendanceSessionRepository attendanceSessionRepository; + private final UserRepository userRepository; + private final SessionUserRepository sessionUserRepository; + private final AttendanceAuthorizationService attendanceAuthorizationService; /** * 출석 세션 생성 - * - GPS 위치 및 반경 설정 (선택사항) - * - 기본 상태 UPCOMING 으로 설정 + * - 세션 생성자(creatorUserId) 정보는 추후 활용 가능 */ - public AttendanceSessionResponse createSession(AttendanceSessionRequest request) { - log.info("출석 세션 생성 시작: 제목={}, 기본시간={}, 출석인정시간={}분", - request.getTitle(), request.getDefaultStartTime(), request.getAllowedMinutes()); - Location location = null; - - if (request.getLatitude() != null && request.getLongitude() != null) { - location = Location.builder() - .lat(request.getLatitude()) - .lng(request.getLongitude()) - .radiusMeters(request.getRadiusMeters()) - .build(); - } + @Transactional + public void createSession(UUID creatorUserId, AttendanceSessionRequest request) { - AttendanceSession session = AttendanceSession.builder() - .title(request.getTitle()) - .allowedMinutes(request.getAllowedMinutes()) - .rewardPoints(request.getRewardPoints()) - .location(location) - .status(SessionStatus.UPCOMING) - .build(); + // 출석 세션 엔티티 생성 + AttendanceSession attendanceSession = AttendanceSession.builder() + .title(request.title()) + .description(request.description()) + .allowedMinutes(request.allowedMinutes()) + .status(SessionStatus.OPEN) + .build(); + + AttendanceSession saved = attendanceSessionRepository.save(attendanceSession); + + User creator = userRepository.findById(creatorUserId).orElseThrow(); - session = attendanceSessionRepository.save(session); + // 세션 생성자를 OWNER로 세션 사용자에 추가 + SessionUser su = SessionUser.builder() + .attendanceSession(saved) + .user(creator) + .sessionRole(SessionRole.OWNER) + .build(); - log.info("출석 세션 생성 완료: 세션ID={}", session.getAttendanceSessionId()); + sessionUserRepository.save(su); + log.info("출석 세션 생성 완료: 세션ID={}, 생성자ID={}", saved.getAttendanceSessionId(), creatorUserId); - return convertToResponse(session); } /** * 세션 ID로 상세 정보 조회 - * - 남은 시간, 참여자 수 등 계산된 정보 포함 */ @Transactional(readOnly = true) - public AttendanceSessionResponse getSessionById(UUID sessionId) { + public AttendanceSessionResponse getSessionById(UUID sessionId, UUID userId) { AttendanceSession session = attendanceSessionRepository.findById(sessionId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); - return convertToResponse(session); + SessionRole role = null; + if (userId != null) { + role = attendanceAuthorizationService.getSessionRole(sessionId, userId); + } + + return AttendanceSessionResponse.from(session, role); } /** * 모든 세션 목록 조회 - * - 관리자용, 공개/비공개 모두 포함 - * - 생성 순으로 정렬 */ @Transactional(readOnly = true) public List getAllSessions() { List sessions = attendanceSessionRepository.findAll(); return sessions.stream() - .map(this::convertToResponse) - .collect(Collectors.toList()); + .map(AttendanceSessionResponse::from) + .toList(); } - /** - * 공개 세션 목록 조회 - * - 학생들이 볼 수 있는 모든 세션만 조회 - * - 생성 순으로 정렬 - */ - @Transactional(readOnly = true) - public List getPublicSessions() { - List sessions = attendanceSessionRepository.findAll(); - - return sessions.stream() - .map(this::convertToResponse) - .collect(Collectors.toList()); - } /** * 활성 세션 목록 조회 @@ -110,92 +102,64 @@ public List getActiveSessions() { return allSessions.stream() .filter(session -> session.getStatus() == SessionStatus.OPEN) - .map(this::convertToResponse) - .collect(Collectors.toList()); + .map(AttendanceSessionResponse::from) + .toList(); } /** - * 세션 정보 수정 - * - 제목, 기본시간, 출석인정시간, 위치, 반경 등 수정 가능 - * - 코드는 변경되지 않음 (보안상 이유) + * 세션 정보 수정(세션 관리자용) */ - public AttendanceSessionResponse updateSession(UUID sessionId, AttendanceSessionRequest request) { + public void updateSession(UUID sessionId, AttendanceSessionRequest request, UUID userId) { log.info("출석 세션 수정 시작: 세션ID={}", sessionId); + // 권한 확인 + attendanceAuthorizationService.ensureAdmin(sessionId, userId); AttendanceSession session = attendanceSessionRepository.findById(sessionId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); - - Location location = null; - if (request.getLatitude() != null && request.getLongitude() != null) { - location = Location.builder() - .lat(request.getLatitude()) - .lng(request.getLongitude()) - .radiusMeters(request.getRadiusMeters()) - .build(); - }else { - location = session.getLocation(); - } + .orElseThrow(()-> new CustomException(ErrorCode.SESSION_NOT_FOUND)); session = session.toBuilder() - .title(request.getTitle()) - .allowedMinutes(request.getAllowedMinutes()) - .rewardPoints(request.getRewardPoints()) - .location(location) + .title(request.title()) + .description(request.description()) + .allowedMinutes(request.allowedMinutes()) .build(); - session = attendanceSessionRepository.save(session); + attendanceSessionRepository.save(session); + + log.info("출석 세션 수정 완료: 세션ID={}", sessionId);} - log.info("출석 세션 수정 완료: 세션ID={}", sessionId); - return convertToResponse(session); - } /** - * 세션 완전 삭제 + * 세션 완전 삭제(관리자 용) * - CASCADE 관련 출석 기록도 함께 삭제 * - 주의: 복구 불가능 */ - public void deleteSession(UUID sessionId) { + public void deleteSession(UUID sessionId, UUID userId) { log.info("출석 세션 삭제 시작: 세션ID={}", sessionId); + // 권한 확인 + attendanceAuthorizationService.ensureAdmin(sessionId, userId); AttendanceSession session = attendanceSessionRepository.findById(sessionId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); + .orElseThrow(()-> new CustomException(ErrorCode.SESSION_NOT_FOUND)); attendanceSessionRepository.delete(session); log.info("출석 세션 삭제 완료: 세션ID={}", sessionId); } - /** - * 세션 수동 활성화 - * - 세션 상태를 OPEN으로 변경 - * - 라운드 기반이므로 세션 상태만 변경 - */ - public void activateSession(UUID sessionId) { - log.info("출석 세션 활성화 시작: 세션ID={}", sessionId); - AttendanceSession session = attendanceSessionRepository.findById(sessionId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); - - session = session.toBuilder() - .status(SessionStatus.OPEN) - .build(); - - attendanceSessionRepository.save(session); - - log.info("출석 세션 활성화 완료: 세션ID={}", sessionId); - } /** - * 세션 수동 종료 + * 세션 수동 종료(해당 세션 관리자용) * - 세션 상태를 CLOSED로 변경 * - 체크인 비활성화 */ - public void closeSession(UUID sessionId) { + public void closeSession(UUID sessionId,UUID userId) { + attendanceAuthorizationService.ensureAdmin(sessionId, userId); log.info("출석 세션 종료 시작: 세션ID={}", sessionId); AttendanceSession session = attendanceSessionRepository.findById(sessionId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); + .orElseThrow(()-> new CustomException(ErrorCode.SESSION_NOT_FOUND)); session = session.toBuilder() .status(SessionStatus.CLOSED) @@ -207,72 +171,12 @@ public void closeSession(UUID sessionId) { } - /** - * 세션 위치 재설정 - * - 기존 위치 정보를 새로운 위치로 업데이트 - * - 반경은 기존 값 유지 또는 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 범위 내 랜덤 생성 - */ - private String generateRandomCode() { - java.security.SecureRandom random = new java.security.SecureRandom(); - StringBuilder code = new StringBuilder(); - for (int i = 0; i < 6; i++) { - code.append(random.nextInt(10)); - } - return code.toString(); - } - /** - * AttendanceSession 엔티티를 Response DTO로 변환 - * - 기본 세션 정보: 제목, 기본 시작 시간, 출석 인정 시간, 보상 포인트 - * - 위치 정보: location 객체 (lat, lng, radiusMeters) - */ - private AttendanceSessionResponse convertToResponse(AttendanceSession session) { - // 위치 정보 변환 (location이 존재하면 LocationInfo 객체 생성, 없으면 null) - AttendanceSessionResponse.LocationInfo location = null; - if (session.getLocation() != null) { - location = AttendanceSessionResponse.LocationInfo.builder() - .lat(session.getLocation().getLat()) - .lng(session.getLocation().getLng()) - .radiusMeters(session.getLocation().getRadiusMeters()) - .build(); - } - - return AttendanceSessionResponse.builder() - .attendanceSessionId(session.getAttendanceSessionId()) - .title(session.getTitle()) - .location(location) - .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 index 9e843dd4..404f5942 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java @@ -1,5 +1,8 @@ package org.sejongisc.backend.attendance.service; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.attendance.dto.SessionUserResponse; @@ -8,16 +11,13 @@ 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.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; 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 @@ -30,191 +30,152 @@ public class SessionUserService { private final AttendanceRepository attendanceRepository; private final UserRepository userRepository; + private final AttendanceAuthorizationService authorizationService; + /** - * 세션에 사용자 추가 - * - 사용자가 이미 참여 중이면 예외 발생 - * - 세션의 이전 라운드들에 대해 자동으로 ABSENT 상태의 Attendance 레코드 생성 - * - * 흐름: - * 1. 세션과 사용자 존재 확인 - * 2. 중복 참여 여부 확인 - * 3. SessionUser 레코드 생성 - * 4. 이전 라운드들에 대해 결석 처리 + * 세션에 사용자 추가 (OWNER 전용 추천) */ - public SessionUserResponse addUserToSession(UUID sessionId, UUID userId) { - log.info("🔧 세션에 사용자 추가 시작: sessionId={}, userId={}", sessionId, userId); + public SessionUserResponse addUserToSession(UUID sessionId, UUID targetUserId, UUID actorUserId) { + log.info("세션 사용자 추가: sessionId={}, targetUserId={}, actorUserId={}", sessionId, targetUserId, actorUserId); + + authorizationService.ensureOwner(sessionId, actorUserId); - // 세션 존재 확인 AttendanceSession session = attendanceSessionRepository.findById(sessionId) - .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다: " + sessionId)); + .orElseThrow(()-> new CustomException(ErrorCode.SESSION_NOT_FOUND)); - // 사용자 존재 확인 - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); + User user = userRepository.findById(targetUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - // 중복 참여 여부 확인 - if (sessionUserRepository.existsBySessionIdAndUserId(sessionId, userId)) { - throw new IllegalArgumentException("이미 세션에 참여 중입니다: " + user.getName()); - } + boolean exists = sessionUserRepository + .existsByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, targetUserId); - log.info("✅ 유효성 검사 완료: sessionId={}, userId={}, userName={}", sessionId, userId, user.getName()); + if (exists) throw new IllegalArgumentException("ALREADY_JOINED"); - // SessionUser 레코드 생성 SessionUser sessionUser = SessionUser.builder() - .attendanceSession(session) - .user(user) - .build(); - - sessionUser = sessionUserRepository.save(sessionUser); - log.info("💾 SessionUser 저장 완료: sessionUserId={}, userName={}", sessionUser.getSessionUserId(), user.getName()); - - // 핵심: 이미 진행된 라운드들에 대해 자동으로 결석 처리 - 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) - .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()); - } + .attendanceSession(session) + .user(user) + .sessionRole(SessionRole.PARTICIPANT) + .build(); - // 6. ⭐ 미래 라운드들에 대해 자동으로 PENDING 상태 처리 - List futureRounds = attendanceRoundRepository.findBySession_SessionIdAndRoundDateAfterOrEqual( - sessionId, - LocalDate.now() - ); - - if (!futureRounds.isEmpty()) { - log.info("📅 미래 라운드 PENDING 처리: 미래 라운드 수={}", futureRounds.size()); - - for (AttendanceRound round : futureRounds) { - // 이미 해당 라운드에 출석 기록이 있는지 확인 - boolean alreadyExists = attendanceRepository.findByAttendanceRound_RoundIdAndUser(round.getRoundId(), user) - .isPresent(); - - if (!alreadyExists) { - // 새로운 Attendance 레코드 생성 (PENDING 상태) - Attendance pendingRecord = Attendance.builder() - .user(user) - .attendanceRound(round) - .attendanceStatus(AttendanceStatus.PENDING) - .build(); - - attendanceRepository.save(pendingRecord); - log.info(" - PENDING 기록 생성: roundId={}, date={}, userName={}", - round.getRoundId(), round.getRoundDate(), user.getName()); - } - } - - log.info("✅ 미래 라운드 PENDING 처리 완료: 처리된 라운드 수={}", futureRounds.size()); - } + SessionUser saved = sessionUserRepository.save(sessionUser); - log.info("✅ 세션에 사용자 추가 완료: sessionId={}, userId={}, userName={}", - sessionId, userId, user.getName()); + createAbsentForPastRounds(sessionId, user); - return convertToResponse(sessionUser); + return SessionUserResponse.from(saved); } /** - * 세션에서 사용자 제거 - * - SessionUser 레코드 삭제 - * - 해당 사용자의 모든 Attendance 레코드도 함께 삭제 (관련된 모든 라운드의 출석 기록 제거) + * 세션에서 사용자 제거 (OWNER 전용 추천) + * - SessionUser 삭제 + * - 해당 유저의 이 세션 관련 Attendance 삭제 */ - public void removeUserFromSession(UUID sessionId, UUID userId) { - log.info("🗑️ 세션에서 사용자 제거 시작: sessionId={}, userId={}", sessionId, userId); + public void removeUserFromSession(UUID sessionId, UUID targetUserId, UUID actorUserId) { + log.info("세션 사용자 제거: sessionId={}, targetUserId={}, actorUserId={}", sessionId, targetUserId, actorUserId); + + authorizationService.ensureOwner(sessionId, actorUserId); - // 1. 세션 존재 확인 AttendanceSession session = attendanceSessionRepository.findById(sessionId) - .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다: " + sessionId)); + .orElseThrow(() -> new IllegalArgumentException("SESSION_NOT_FOUND")); - // 2. 사용자 존재 확인 - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); + // SessionUser 삭제 + sessionUserRepository.deleteByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, targetUserId); + + // 해당 세션의 라운드들에서 targetUserId의 출석 레코드 삭제 + attendanceRepository.deleteAllByAttendanceRound_AttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, targetUserId); + } + + /** + * 세션 참여자 조회 (멤버면 조회 가능 / 또는 공개) + */ + @Transactional(readOnly = true) + public List getSessionUsers(UUID sessionId, UUID viewerUserId) { + authorizationService.ensureMember(sessionId, viewerUserId); - // 3. SessionUser 레코드 삭제 - sessionUserRepository.deleteBySessionIdAndUserId(sessionId, userId); - log.info("💾 SessionUser 레코드 삭제 완료: userName={}", user.getName()); + List users = sessionUserRepository + .findByAttendanceSession_AttendanceSessionId(sessionId); - // 4. ⭐ 해당 세션의 모든 Attendance 레코드 삭제 (해당 라운드별 출석 기록 모두 제거) - List attendancesToDelete = attendanceRepository.findAllBySessionAndUserId(session, userId); + return users.stream().map(SessionUserResponse::from).toList(); + } - if (!attendancesToDelete.isEmpty()) { - log.info("🗑️ Attendance 레코드 삭제 시작: 삭제 대상 수={}", attendancesToDelete.size()); + @Transactional(readOnly = true) + public boolean isUserInSession(UUID sessionId, UUID userId) { + return sessionUserRepository.existsByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, userId); + } - attendanceRepository.deleteAll(attendancesToDelete); + private void createAbsentForPastRounds(UUID sessionId, User user) { + List pastRounds = attendanceRoundRepository + .findByAttendanceSession_AttendanceSessionIdAndRoundDateBefore(sessionId, LocalDate.now()); - log.info("✅ Attendance 레코드 삭제 완료: 삭제된 레코드 수={}", attendancesToDelete.size()); - for (Attendance a : attendancesToDelete) { - log.info(" - 삭제됨: roundId={}, status={}", - a.getAttendanceRound() != null ? a.getAttendanceRound().getRoundId() : "null", - a.getAttendanceStatus()); - } - } + for (AttendanceRound round : pastRounds) { + boolean already = attendanceRepository.findByAttendanceRound_RoundIdAndUser(round.getRoundId(), user).isPresent(); + if (already) continue; - log.info("✅ 세션에서 사용자 제거 완료: sessionId={}, userId={}, userName={}", - sessionId, userId, user.getName()); + Attendance absent = Attendance.builder() + .user(user) + .attendanceRound(round) + .attendanceStatus(AttendanceStatus.ABSENT) + .note("세션 중간 참여 - 이전 라운드는 자동 결석 처리") + .build(); + + attendanceRepository.save(absent); + } } /** - * 세션의 모든 참여자 조회 + * 세션 가입 */ - @Transactional(readOnly = true) - public List getSessionUsers(UUID sessionId) { - log.info("📋 세션 참여자 조회: sessionId={}", sessionId); + @Transactional + public void joinSession(UUID sessionId, UUID userId) { + // 이미 가입했는지 체크 + boolean exists = sessionUserRepository.existsByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, userId); + if (exists) throw new IllegalStateException("ALREADY_JOINED"); AttendanceSession session = attendanceSessionRepository.findById(sessionId) - .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다: " + sessionId)); - - List sessionUsers = sessionUserRepository.findBySessionId(sessionId); - - log.info("📊 세션 참여자 조회 결과: sessionId={}, 참여자 수={}", - sessionId, sessionUsers.size()); + .orElseThrow(()-> new CustomException(ErrorCode.SESSION_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(()-> new CustomException(ErrorCode.USER_NOT_FOUND)); - return sessionUsers.stream() - .map(this::convertToResponse) - .collect(Collectors.toList()); + sessionUserRepository.save(SessionUser.builder() + .attendanceSession(session) + .user(user) + .sessionRole(SessionRole.PARTICIPANT) + .build()); } /** - * 특정 사용자가 세션에 참여하는지 확인 + * 세션 탈퇴 */ - @Transactional(readOnly = true) - public boolean isUserInSession(UUID sessionId, UUID userId) { - return sessionUserRepository.existsBySessionIdAndUserId(sessionId, userId); + @Transactional + public void leaveSession(UUID sessionId, UUID userId) { + SessionUser su = sessionUserRepository + .findByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, userId) + .orElseThrow(() -> new IllegalStateException("NOT_SESSION_MEMBER")); + + sessionUserRepository.delete(su); } /** - * SessionUser를 SessionUserResponse로 변환 + * 세션 관리자 추가/제거 */ - private SessionUserResponse convertToResponse(SessionUser sessionUser) { - return SessionUserResponse.builder() - .sessionUserId(sessionUser.getSessionUserId()) - .userId(sessionUser.getUser().getUserId()) - .sessionId(sessionUser.getAttendanceSession().getAttendanceSessionId()) - .createdAt(sessionUser.getCreatedDate()) - .build(); + @Transactional + public void addAdmin(UUID sessionId, UUID targetUserId) { + SessionUser su = sessionUserRepository + .findByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, targetUserId) + .orElseThrow(() -> new IllegalStateException("TARGET_NOT_SESSION_MEMBER")); + su.changeRole(SessionRole.MANAGER); } + + @Transactional + public void removeAdmin(UUID sessionId, UUID targetUserId) { + SessionUser su = sessionUserRepository + .findByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, targetUserId) + .orElseThrow(() -> new IllegalStateException("TARGET_NOT_SESSION_MEMBER")); + + // OWNER를 강제로 내릴지 여부는 정책 + if (su.getSessionRole() == SessionRole.OWNER) { + throw new IllegalStateException("CANNOT_DEMOTE_OWNER"); + } + su.changeRole(SessionRole.PARTICIPANT); + } + } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/util/QrTokenUtil.java b/backend/src/main/java/org/sejongisc/backend/attendance/util/QrTokenUtil.java new file mode 100644 index 00000000..9b784e57 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/util/QrTokenUtil.java @@ -0,0 +1,83 @@ +package org.sejongisc.backend.attendance.util; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public final class QrTokenUtil { + private static final SecureRandom RNG = new SecureRandom(); + private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding(); + + private QrTokenUtil() {} + + /** 라운드별 비밀키(절대 노출 X) 생성 */ + public static String generateSecret() { + byte[] buf = new byte[32]; // 256-bit + RNG.nextBytes(buf); + return B64URL.encodeToString(buf); + } + + /** qrToken 발급 */ + public static IssuedToken issue(UUID roundId, String qrSecret, long ttlSeconds) { + long expiresAt = Instant.now().getEpochSecond() + ttlSeconds; + String data = roundId + ":" + expiresAt; + String sig = sign(data, qrSecret); + return new IssuedToken(data + ":" + sig, expiresAt); + } + + /** qrToken 검증 + 파싱(서명/만료만 검사) */ + public static ParsedToken verifyAndParse(String token, String qrSecret) { + if (token == null || token.isBlank()) throw new IllegalStateException("QR_TOKEN_REQUIRED"); + + String[] parts = token.split(":"); + if (parts.length != 3) throw new IllegalStateException("QR_TOKEN_MALFORMED"); + + UUID roundId; + long expiresAt; + try { + roundId = UUID.fromString(parts[0]); + expiresAt = Long.parseLong(parts[1]); + } catch (Exception e) { + throw new IllegalStateException("QR_TOKEN_MALFORMED"); + } + + long now = Instant.now().getEpochSecond(); + if (now > expiresAt) throw new IllegalStateException("QR_TOKEN_EXPIRED"); + + String data = parts[0] + ":" + parts[1]; + String expectedSig = sign(data, qrSecret); + + if (!constantTimeEquals(expectedSig, parts[2])) { + throw new IllegalStateException("QR_TOKEN_INVALID"); + } + + return new ParsedToken(roundId, expiresAt); + } + + private static String sign(String data, String qrSecretB64Url) { + try { + byte[] key = Base64.getUrlDecoder().decode(qrSecretB64Url); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return B64URL.encodeToString(raw); + } catch (Exception e) { + throw new IllegalStateException("QR_SIGN_ERROR"); + } + } + + private static boolean constantTimeEquals(String a, String b) { + return MessageDigest.isEqual( + a.getBytes(StandardCharsets.UTF_8), + b.getBytes(StandardCharsets.UTF_8) + ); + } + + public record IssuedToken(String token, long expiresAtEpochSec) {} + public record ParsedToken(UUID roundId, long expiresAtEpochSec) {} +} + diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index 64982b4b..f78299cb 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -101,7 +101,11 @@ public enum ErrorCode { BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 게시판을 찾을 수 없습니다."), - INVALID_BOARD_TYPE(HttpStatus.BAD_REQUEST, "상위 게시판에는 글을 작성할 수 없습니다."); + INVALID_BOARD_TYPE(HttpStatus.BAD_REQUEST, "상위 게시판에는 글을 작성할 수 없습니다."), + + // SESSION + + SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 출석 세션이 존재하지 않습니다."); diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java index cc7a0925..61e36c7e 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java @@ -1,12 +1,6 @@ package org.sejongisc.backend.attendance.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -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.AttendanceStatus; import org.sejongisc.backend.attendance.service.AttendanceService; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.user.entity.Role; @@ -51,206 +45,5 @@ public class AttendanceControllerTest { private JpaMetamodelMappingContext jpaMetamodelMappingContext; - void checkIn_fail_validation() throws Exception { - //given - AttendanceRequest request = AttendanceRequest.builder() - .code("12345") - .latitude(91.0) - .longitude(181.0) - .build(); - User user = User.builder() - .userId(UUID.randomUUID()) - .name("오찬혁") - .email("oh@example.com") - .role(Role.TEAM_MEMBER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(user); - - //then - UUID sessionId = UUID.randomUUID(); - mockMvc.perform(post("/api/attendance/sessions/{sessionId}/check-in", sessionId) - .with(user(userDetails)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isInternalServerError()); - } - - @Test - @DisplayName("내 출석 기록 조회 성공") - @WithMockUser - void getMyAttendances_success() throws Exception { - //given - User user = User.builder() - .userId(UUID.randomUUID()) - .name("오찬혁") - .email("oh@example.com") - .role(Role.TEAM_MEMBER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(user); - - List responses = Arrays.asList( - AttendanceResponse.builder() - .attendanceId(UUID.randomUUID()) - .userId(user.getUserId()) - .userName("오찬혁") - .attendanceSessionId(UUID.randomUUID()) - .attendanceStatus(AttendanceStatus.PRESENT) - .checkedAt(LocalDateTime.now().minusDays(1)) - .awardedPoints(10) - .build(), - AttendanceResponse.builder() - .attendanceId(UUID.randomUUID()) - .userId(user.getUserId()) - .userName("오찬혁") - .attendanceSessionId(UUID.randomUUID()) - .attendanceStatus(AttendanceStatus.LATE) - .checkedAt(LocalDateTime.now()) - .awardedPoints(5) - .build() - ); - - when(attendanceService.getAttendancesByUser(eq(user.getUserId()))).thenReturn(responses); - - //then - mockMvc.perform(get("/api/attendance/history") - .with(user(userDetails))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].attendanceStatus").value("PRESENT")) - .andExpect(jsonPath("$[0].awardedPoints").value(10)) - .andExpect(jsonPath("$[1].attendanceStatus").value("LATE")) - .andExpect(jsonPath("$[1].awardedPoints").value(5)); - } - - @Test - @DisplayName("세션별 출석 목록 조회 성공 (관리자)") - @WithMockUser(roles = "PRESIDENT") - void getAttendancesBySession_success() throws Exception { - //given - UUID sessionId = UUID.randomUUID(); - List responses = Arrays.asList( - AttendanceResponse.builder() - .attendanceId(UUID.randomUUID()) - .userId(UUID.randomUUID()) - .userName("오찬혁") - .attendanceSessionId(sessionId) - .attendanceStatus(AttendanceStatus.PRESENT) - .checkedAt(LocalDateTime.now()) - .awardedPoints(10) - .build(), - AttendanceResponse.builder() - .attendanceId(UUID.randomUUID()) - .userId(UUID.randomUUID()) - .userName("김찬혁") - .attendanceSessionId(sessionId) - .attendanceStatus(AttendanceStatus.LATE) - .checkedAt(LocalDateTime.now()) - .awardedPoints(5) - .build() - ); - - when(attendanceService.getAttendancesBySession(sessionId)).thenReturn(responses); - - //then - mockMvc.perform(get("/api/attendance/sessions/{sessionId}/attendances", sessionId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].userName").value("오찬혁")) - .andExpect(jsonPath("$[0].attendanceStatus").value("PRESENT")) - .andExpect(jsonPath("$[1].userName").value("김찬혁")) - .andExpect(jsonPath("$[1].attendanceStatus").value("LATE")); - } - - @Test - @DisplayName("세션별 출석 목록 조회 실패: 권한 없음") - void getAttendancesBySession_fail_noPermission() throws Exception { - //given - UUID sessionId = UUID.randomUUID(); - - User teamMemberUser = User.builder() - .userId(UUID.randomUUID()) - .name("멤버") - .email("member@example.com") - .role(Role.TEAM_MEMBER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(teamMemberUser); - - //then - mockMvc.perform(get("/api/attendance/sessions/{sessionId}/attendances", sessionId) - .with(user(userDetails))) - .andExpect(status().isForbidden()); - } - - @Test - @DisplayName("출석 상태 수정 성공 (관리자)") - void updateAttendanceStatus_success() throws Exception { - //given - UUID attendanceId = UUID.randomUUID(); - String status = "PRESENT"; - String reason = "관리자 수정"; - - User adminUser = User.builder() - .userId(UUID.randomUUID()) - .name("관리자") - .email("admin@example.com") - .role(Role.PRESIDENT) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(adminUser); - - AttendanceResponse response = AttendanceResponse.builder() - .attendanceId(attendanceId) - .userId(UUID.randomUUID()) - .userName("오찬혁") - .attendanceSessionId(UUID.randomUUID()) - .attendanceStatus(AttendanceStatus.PRESENT) - .checkedAt(LocalDateTime.now()) - .awardedPoints(10) - .note(reason) - .build(); - - when(attendanceService.updateAttendanceStatus( - any(UUID.class), eq(attendanceId), eq(status), eq(reason), eq(adminUser.getUserId()) - )).thenReturn(response); - - //then - UUID sessionId = UUID.randomUUID(); - mockMvc.perform(post("/api/attendance/sessions/{sessionId}/attendances/{memberId}", sessionId, attendanceId) - .with(authentication(new UsernamePasswordAuthenticationToken( - userDetails, null, Collections.singletonList(new SimpleGrantedAuthority("ROLE_PRESIDENT")) - ))) - .param("status", status) - .param("reason", reason)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.attendanceStatus").value("PRESENT")) - .andExpect(jsonPath("$.note").value(reason)); - } - - @Test - @DisplayName("출석 상태 수정 실패: 권한 없음") - void updateAttendanceStatus_fail_noPermission() throws Exception { - //given - UUID attendanceId = UUID.randomUUID(); - UUID sessionId = UUID.randomUUID(); - - User teamMemberUser = User.builder() - .userId(UUID.randomUUID()) - .name("member@example.com") - .role(Role.TEAM_MEMBER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(teamMemberUser); - - //then - mockMvc.perform(post("/api/attendance/sessions/{sessionId}/attendances/{memberId}", sessionId, attendanceId) - .with(user(userDetails)) - .param("status", "PRESENT") - .param("reason", "사유")) - .andExpect(status().isForbidden()); - - } } diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceRoundControllerTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceRoundControllerTest.java index 378699c2..5b46e986 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceRoundControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceRoundControllerTest.java @@ -1,15 +1,7 @@ package org.sejongisc.backend.attendance.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.sejongisc.backend.attendance.dto.AttendanceCheckInRequest; -import org.sejongisc.backend.attendance.dto.AttendanceCheckInResponse; -import org.sejongisc.backend.attendance.dto.AttendanceResponse; -import org.sejongisc.backend.attendance.dto.AttendanceRoundRequest; -import org.sejongisc.backend.attendance.dto.AttendanceRoundResponse; -import org.sejongisc.backend.attendance.entity.AttendanceStatus; -import org.sejongisc.backend.attendance.entity.RoundStatus; + import org.sejongisc.backend.attendance.service.AttendanceRoundService; import org.sejongisc.backend.attendance.service.AttendanceService; import org.sejongisc.backend.common.auth.jwt.JwtProvider; @@ -61,352 +53,5 @@ public class AttendanceRoundControllerTest { private UUID roundId = UUID.randomUUID(); private UUID sessionId = UUID.randomUUID(); - @Test - @DisplayName("라운드 생성 성공") - @WithMockUser(roles = "PRESIDENT") - void createRound_success() throws Exception { - // given - LocalDate roundDate = LocalDate.now(); - LocalTime startTime = LocalTime.of(14, 0); - - AttendanceRoundRequest request = AttendanceRoundRequest.builder() - .roundDate(roundDate) - .startTime(startTime) - .allowedMinutes(30) - .build(); - - AttendanceRoundResponse response = AttendanceRoundResponse.builder() - .id(roundId) - .date(roundDate) - .startTime(startTime) - .availableMinutes(30) - .status("upcoming") - .build(); - - when(attendanceRoundService.createRound(any(UUID.class), any(AttendanceRoundRequest.class))) - .thenReturn(response); - - // when & then - mockMvc.perform(post("/api/attendance/sessions/{sessionId}/rounds", sessionId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").value(roundId.toString())) - .andExpect(jsonPath("$.date").value(roundDate.toString())) - .andExpect(jsonPath("$.startTime").value("14:00:00")) - .andExpect(jsonPath("$.availableMinutes").value(30)) - .andExpect(jsonPath("$.status").value("upcoming")); - } - - @Test - @DisplayName("라운드 생성 실패: 권한 없음") - @WithMockUser(roles = "TEAM_MEMBER") - void createRound_fail_noPermission() throws Exception { - // given - AttendanceRoundRequest request = AttendanceRoundRequest.builder() - .roundDate(LocalDate.now()) - .startTime(LocalTime.of(14, 0)) - .allowedMinutes(30) - .build(); - - // when & then - mockMvc.perform(post("/api/attendance/sessions/{sessionId}/rounds", sessionId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isForbidden()); - } - - @Test - @DisplayName("라운드 조회 성공") - @WithMockUser - void getRound_success() throws Exception { - // given - LocalDate roundDate = LocalDate.now(); - LocalTime startTime = LocalTime.of(14, 0); - - AttendanceRoundResponse response = AttendanceRoundResponse.builder() - .id(roundId) - .date(roundDate) - .startTime(startTime) - .availableMinutes(30) - .status("active") - .build(); - - when(attendanceRoundService.getRound(roundId)).thenReturn(response); - - // when & then - mockMvc.perform(get("/api/attendance/rounds/{roundId}", roundId) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(roundId.toString())) - .andExpect(jsonPath("$.status").value("active")); - } - - @Test - @DisplayName("세션의 라운드 목록 조회 성공") - @WithMockUser - void getRoundsBySession_success() throws Exception { - // given - LocalDate roundDate = LocalDate.now(); - LocalTime startTime = LocalTime.of(14, 0); - - AttendanceRoundResponse round1 = AttendanceRoundResponse.builder() - .id(UUID.randomUUID()) - .date(roundDate) - .startTime(startTime) - .availableMinutes(30) - .status("active") - .build(); - - AttendanceRoundResponse round2 = AttendanceRoundResponse.builder() - .id(UUID.randomUUID()) - .date(roundDate.plusDays(7)) - .startTime(startTime) - .availableMinutes(30) - .status("upcoming") - .build(); - - List responses = Arrays.asList(round1, round2); - when(attendanceRoundService.getRoundsBySession(sessionId)).thenReturn(responses); - - // when & then - mockMvc.perform(get("/api/attendance/sessions/{sessionId}/rounds", sessionId) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].status").value("active")) - .andExpect(jsonPath("$[1].status").value("upcoming")); - } - - @Test - @DisplayName("라운드 정보 수정 성공") - @WithMockUser(roles = "PRESIDENT") - void updateRound_success() throws Exception { - // given - LocalDate newDate = LocalDate.now().plusDays(1); - LocalTime newStartTime = LocalTime.of(15, 0); - - AttendanceRoundRequest request = AttendanceRoundRequest.builder() - .roundDate(newDate) - .startTime(newStartTime) - .allowedMinutes(45) - .build(); - - AttendanceRoundResponse response = AttendanceRoundResponse.builder() - .id(roundId) - .date(newDate) - .startTime(newStartTime) - .availableMinutes(45) - .status("upcoming") - .build(); - - when(attendanceRoundService.updateRound(any(UUID.class), any(AttendanceRoundRequest.class))) - .thenReturn(response); - - // when & then - mockMvc.perform(put("/api/attendance/rounds/{roundId}", roundId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.date").value(newDate.toString())) - .andExpect(jsonPath("$.startTime").value("15:00:00")) - .andExpect(jsonPath("$.availableMinutes").value(45)); - } - - @Test - @DisplayName("라운드 삭제 성공") - @WithMockUser(roles = "PRESIDENT") - void deleteRound_success() throws Exception { - // given - doNothing().when(attendanceRoundService).deleteRound(roundId); - - // when & then - mockMvc.perform(delete("/api/attendance/rounds/{roundId}", roundId) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isNoContent()); - } - - @Test - @DisplayName("라운드 출석 체크인 성공") - @WithMockUser - void checkInByRound_success() throws Exception { - // given - UUID userId = UUID.randomUUID(); - - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .latitude(37.4979) - .longitude(127.0276) - .userName("김철수") - .build(); - - AttendanceCheckInResponse response = AttendanceCheckInResponse.builder() - .roundId(roundId) - .success(true) - .status("PRESENT") - .failureReason(null) - .checkedAt(LocalDateTime.now()) - .awardedPoints(10) - .remainingSeconds(1200L) - .build(); - - when(jwtProvider.getUserIdFromToken(anyString())).thenReturn(userId.toString()); - when(attendanceService.checkInByRound(any(AttendanceCheckInRequest.class), any(UUID.class))) - .thenReturn(response); - - // when & then - mockMvc.perform(post("/api/attendance/rounds/check-in") - .header("Authorization", "Bearer test-token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value("PRESENT")) - .andExpect(jsonPath("$.awardedPoints").value(10)) - .andExpect(jsonPath("$.failureReason").doesNotExist()); - } - - @Test - @DisplayName("라운드 출석 체크인 실패: UPCOMING 상태") - @WithMockUser - void checkInByRound_fail_upcoming() throws Exception { - // given - UUID userId = UUID.randomUUID(); - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .latitude(37.4979) - .longitude(127.0276) - .userName("김철수") - .build(); - - when(jwtProvider.getUserIdFromToken(anyString())).thenReturn(userId.toString()); - - // when & then - mockMvc.perform(post("/api/attendance/rounds/check-in") - .header("Authorization", "Bearer test-token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("라운드 출석 체크인 실패: CLOSED 상태") - @WithMockUser - void checkInByRound_fail_closed() throws Exception { - // given - UUID userId = UUID.randomUUID(); - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .latitude(37.4979) - .longitude(127.0276) - .build(); - - when(jwtProvider.getUserIdFromToken(anyString())).thenReturn(userId.toString()); - - // when & then - mockMvc.perform(post("/api/attendance/rounds/check-in") - .header("Authorization", "Bearer test-token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("특정 날짜의 라운드 조회 성공") - @WithMockUser - void getRoundByDate_success() throws Exception { - // given - LocalDate targetDate = LocalDate.now(); - LocalTime startTime = LocalTime.of(14, 0); - - AttendanceRoundResponse response = AttendanceRoundResponse.builder() - .id(roundId) - .date(targetDate) - .startTime(startTime) - .availableMinutes(30) - .status("active") - .build(); - - when(attendanceRoundService.getRoundByDate(sessionId, targetDate)).thenReturn(response); - - // when & then - mockMvc.perform(get("/api/attendance/sessions/{sessionId}/rounds/by-date", sessionId) - .param("date", targetDate.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("active")); - } - - @Test - @DisplayName("라운드별 출석 명단 조회 성공") - @WithMockUser - void getAttendancesByRound_success() throws Exception { - // given - List attendanceList = Arrays.asList( - AttendanceResponse.builder() - .attendanceId(UUID.randomUUID()) - .userId(UUID.randomUUID()) - .userName("김철수") - .attendanceSessionId(sessionId) - .attendanceRoundId(roundId) - .attendanceStatus(AttendanceStatus.PRESENT) - .build(), - AttendanceResponse.builder() - .attendanceId(UUID.randomUUID()) - .userId(UUID.randomUUID()) - .userName("이영희") - .attendanceSessionId(sessionId) - .attendanceRoundId(roundId) - .attendanceStatus(AttendanceStatus.LATE) - .build() - ); - - when(attendanceService.getAttendancesByRound(roundId)) - .thenReturn(attendanceList); - - // when & then - mockMvc.perform(get("/api/attendance/rounds/{roundId}/attendances", roundId) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].userName").value("김철수")) - .andExpect(jsonPath("$[0].attendanceStatus").value("PRESENT")) - .andExpect(jsonPath("$[1].userName").value("이영희")) - .andExpect(jsonPath("$[1].attendanceStatus").value("LATE")); - } - - @Test - @DisplayName("익명 사용자 출석 체크인: 이름 없음 (자동 생성)") - @WithMockUser - void checkInByRound_anonymous_noName() throws Exception { - // given - UUID userId = UUID.randomUUID(); - AttendanceCheckInRequest request = AttendanceCheckInRequest.builder() - .roundId(roundId) - .latitude(37.4979) - .longitude(127.0276) - // userName을 입력하지 않음 - .build(); - - AttendanceCheckInResponse response = AttendanceCheckInResponse.builder() - .roundId(roundId) - .success(true) - .status("PRESENT") - .failureReason(null) - .checkedAt(LocalDateTime.now()) - .awardedPoints(10) - .remainingSeconds(1200L) - .build(); - - when(jwtProvider.getUserIdFromToken(anyString())).thenReturn(userId.toString()); - when(attendanceService.checkInByRound(any(AttendanceCheckInRequest.class), any(UUID.class))) - .thenReturn(response); - // when & then - mockMvc.perform(post("/api/attendance/rounds/check-in") - .header("Authorization", "Bearer test-token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value("PRESENT")); - } } diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java index 9dbc0f6d..763c22d4 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java @@ -6,9 +6,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.sejongisc.backend.attendance.dto.AttendanceCheckInRequest; -import org.sejongisc.backend.attendance.dto.AttendanceCheckInResponse; -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; diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundServiceTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundServiceTest.java index 05de70fa..5610d06a 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundServiceTest.java @@ -1,33 +1,15 @@ package org.sejongisc.backend.attendance.service; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -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 java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) public class AttendanceRoundServiceTest { diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java index d84406b4..e5f48360 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java @@ -5,8 +5,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -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; import org.sejongisc.backend.attendance.repository.AttendanceSessionRepository; diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/SessionLocationUpdateTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/SessionLocationUpdateTest.java index a9219edc..517f78b3 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/SessionLocationUpdateTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/SessionLocationUpdateTest.java @@ -6,23 +6,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.sejongisc.backend.attendance.dto.SessionLocationUpdateRequest; -import org.sejongisc.backend.attendance.dto.AttendanceSessionResponse; -import org.sejongisc.backend.attendance.entity.AttendanceSession; -import org.sejongisc.backend.attendance.entity.Location; -import org.sejongisc.backend.attendance.entity.SessionStatus; import org.sejongisc.backend.attendance.repository.AttendanceRepository; import org.sejongisc.backend.attendance.repository.AttendanceSessionRepository; -import java.time.LocalDateTime; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) public class SessionLocationUpdateTest { @@ -36,165 +22,5 @@ public class SessionLocationUpdateTest { @InjectMocks private AttendanceSessionService attendanceSessionService; - @Test - @DisplayName("세션 위치 재설정 성공 - 기존 위치 있음") - void updateSessionLocation_success_withExistingLocation() { - // given - UUID sessionId = UUID.randomUUID(); - Double newLatitude = 37.4979; - Double newLongitude = 127.0276; - - SessionLocationUpdateRequest request = SessionLocationUpdateRequest.builder() - .latitude(newLatitude) - .longitude(newLongitude) - .build(); - - Location existingLocation = Location.builder() - .lat(37.5665) - .lng(126.9780) - .radiusMeters(200) - .build(); - - AttendanceSession existingSession = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("테스트 세션") - .code("123456") - .startsAt(LocalDateTime.now().plusHours(1)) - .windowSeconds(1800) - .location(existingLocation) - .status(SessionStatus.UPCOMING) - .build(); - - AttendanceSession updatedSession = existingSession.toBuilder() - .location(Location.builder() - .lat(newLatitude) - .lng(newLongitude) - .radiusMeters(200) // 기존 반경 유지 - .build()) - .build(); - - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(existingSession)); - when(attendanceSessionRepository.save(any(AttendanceSession.class))).thenReturn(updatedSession); - - // when - AttendanceSessionResponse response = attendanceSessionService.updateSessionLocation(sessionId, request); - - // then - assertAll( - () -> assertThat(response.getLocation().getLat()).isEqualTo(newLatitude), - () -> assertThat(response.getLocation().getLng()).isEqualTo(newLongitude) - ); - - verify(attendanceSessionRepository).save(any(AttendanceSession.class)); - } - - @Test - @DisplayName("세션 위치 재설정 성공 - 기존 위치 없음") - void updateSessionLocation_success_withoutExistingLocation() { - // given - UUID sessionId = UUID.randomUUID(); - Double newLatitude = 37.4979; - Double newLongitude = 127.0276; - - SessionLocationUpdateRequest request = SessionLocationUpdateRequest.builder() - .latitude(newLatitude) - .longitude(newLongitude) - .build(); - - AttendanceSession existingSession = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("테스트 세션") - .code("123456") - .startsAt(LocalDateTime.now().plusHours(1)) - .windowSeconds(1800) - .location(null) // 기존 위치 없음 - .status(SessionStatus.UPCOMING) - .build(); - - AttendanceSession updatedSession = existingSession.toBuilder() - .location(Location.builder() - .lat(newLatitude) - .lng(newLongitude) - .radiusMeters(100) // 기본값 100m - .build()) - .build(); - - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(existingSession)); - when(attendanceSessionRepository.save(any(AttendanceSession.class))).thenReturn(updatedSession); - - // when - AttendanceSessionResponse response = attendanceSessionService.updateSessionLocation(sessionId, request); - - // then - assertAll( - () -> assertThat(response.getLocation().getLat()).isEqualTo(newLatitude), - () -> assertThat(response.getLocation().getLng()).isEqualTo(newLongitude) - ); - - verify(attendanceSessionRepository).save(any(AttendanceSession.class)); - } - - @Test - @DisplayName("세션 위치 재설정 실패 - 존재하지 않는 세션") - void updateSessionLocation_fail_sessionNotFound() { - // given - UUID sessionId = UUID.randomUUID(); - SessionLocationUpdateRequest request = SessionLocationUpdateRequest.builder() - .latitude(37.4979) - .longitude(127.0276) - .build(); - - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.empty()); - - // then - assertThatThrownBy(() -> attendanceSessionService.updateSessionLocation(sessionId, request)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 세션입니다: " + sessionId); - } - - @Test - @DisplayName("세션 위치 재설정 - 반경 유지 확인") - void updateSessionLocation_verifyRadiusPreservation() { - // given - UUID sessionId = UUID.randomUUID(); - int customRadius = 500; - - SessionLocationUpdateRequest request = SessionLocationUpdateRequest.builder() - .latitude(37.4979) - .longitude(127.0276) - .build(); - - Location existingLocation = Location.builder() - .lat(37.5665) - .lng(126.9780) - .radiusMeters(customRadius) - .build(); - - AttendanceSession existingSession = AttendanceSession.builder() - .attendanceSessionId(sessionId) - .title("테스트 세션") - .code("123456") - .startsAt(LocalDateTime.now().plusHours(1)) - .windowSeconds(1800) - .location(existingLocation) - .status(SessionStatus.UPCOMING) - .build(); - - AttendanceSession updatedSession = existingSession.toBuilder() - .location(Location.builder() - .lat(37.4979) - .lng(127.0276) - .radiusMeters(customRadius) - .build()) - .build(); - - when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(existingSession)); - when(attendanceSessionRepository.save(any(AttendanceSession.class))).thenReturn(updatedSession); - - // when - AttendanceSessionResponse response = attendanceSessionService.updateSessionLocation(sessionId, request); - // then - // radiusMeters is no longer available in AttendanceSessionResponse - } } From 338c4d5e2e720d9de2de4e9ae5d307a706787ec0 Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Mon, 19 Jan 2026 17:27:26 +0900 Subject: [PATCH 03/14] =?UTF-8?q?fix(=EC=88=98=EC=A0=95):=20userId?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AttendanceController.java | 2 +- .../controller/AttendanceRoundController.java | 10 +++------- .../AttendanceSessionController.java | 20 ++++++++++++------- .../controller/SessionUserController.java | 16 +++++++++++---- .../dto/AttendanceSessionResponse.java | 6 +----- .../attendance/entity/SessionUser.java | 1 + .../backend/attendance/util/AuthUserUtil.java | 14 +++++++++++++ 7 files changed, 45 insertions(+), 24 deletions(-) create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java 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 c57a75f1..0fed82b3 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 @@ -33,7 +33,7 @@ public class AttendanceController { @PostMapping("/check-in") public ResponseEntity checkIn( @AuthenticationPrincipal CustomUserDetails userDetails, - String qrToken + @RequestParam String qrToken ) { UUID userId = requireUserId(userDetails); attendanceService.checkIn(userId, qrToken); 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 c553a6f8..bb4cd6d1 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 @@ -1,5 +1,7 @@ package org.sejongisc.backend.attendance.controller; +import static org.sejongisc.backend.attendance.util.AuthUserUtil.requireUserId; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -8,6 +10,7 @@ import org.sejongisc.backend.attendance.dto.*; import org.sejongisc.backend.attendance.service.AttendanceRoundService; import org.sejongisc.backend.attendance.service.AttendanceService; +import org.sejongisc.backend.attendance.util.AuthUserUtil; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -28,7 +31,6 @@ public class AttendanceRoundController { private final AttendanceRoundService attendanceRoundService; - private final AttendanceService attendanceService; /** * 라운드 생성 (관리자/OWNER) @@ -117,10 +119,4 @@ public ResponseEntity deleteRound( } - // -------- private helpers -------- - - private UUID requireUserId(CustomUserDetails userDetails) { - if (userDetails == null) throw new IllegalStateException("UNAUTHENTICATED"); - return userDetails.getUserId(); - } } 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 bb4eaf26..2d8a460e 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 @@ -1,5 +1,7 @@ package org.sejongisc.backend.attendance.controller; +import static org.sejongisc.backend.attendance.util.AuthUserUtil.requireUserId; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -9,6 +11,7 @@ import org.sejongisc.backend.attendance.service.AttendanceAuthorizationService; import org.sejongisc.backend.attendance.service.AttendanceSessionService; import org.sejongisc.backend.attendance.service.SessionUserService; +import org.sejongisc.backend.attendance.util.AuthUserUtil; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.user.service.UserService; import org.sejongisc.backend.user.service.projection.UserIdNameProjection; @@ -32,8 +35,7 @@ public class AttendanceSessionController { private final AttendanceSessionService attendanceSessionService; - private final SessionUserService sessionUserService; - private final UserService userService; + /** * 출석 세션 생성 */ @@ -58,6 +60,7 @@ public ResponseEntity createSession(@AuthenticationPrincipal(expression = @RequestBody AttendanceSessionRequest request) { log.info("출석 세션 생성 요청: 제목={}", request.title()); + attendanceSessionService.createSession(userId, request); return ResponseEntity.status(HttpStatus.CREATED).build(); @@ -87,8 +90,8 @@ public ResponseEntity getSession( @PathVariable UUID sessionId, @AuthenticationPrincipal CustomUserDetails userDetails ) { - UUID userId = (userDetails != null) ? userDetails.getUserId() : null; - AttendanceSessionResponse response = attendanceSessionService.getSessionById(sessionId, userId); + UUID adminUserId = requireUserId(userDetails); + AttendanceSessionResponse response = attendanceSessionService.getSessionById(sessionId, adminUserId); return ResponseEntity.ok(response); } @@ -169,9 +172,10 @@ public ResponseEntity updateSession( @PathVariable UUID sessionId, @RequestBody AttendanceSessionRequest request, @AuthenticationPrincipal CustomUserDetails userDetails){ + UUID adminUserId = requireUserId(userDetails); log.info("출석 세션 수정: 세션ID={}", sessionId); - attendanceSessionService.updateSession(sessionId, request,userDetails.getUserId()); + attendanceSessionService.updateSession(sessionId, request,adminUserId); log.info("출석 세션 수정 완료: 세션ID={}", sessionId); @@ -194,9 +198,10 @@ public ResponseEntity updateSession( @PostMapping("/{sessionId}/close") public ResponseEntity closeSession(@PathVariable UUID sessionId,@AuthenticationPrincipal CustomUserDetails userDetails) { log.info("출석 세션 종료: 세션ID={}", sessionId); + UUID adminUserId = requireUserId(userDetails); - attendanceSessionService.closeSession(sessionId,userDetails.getUserId()); + attendanceSessionService.closeSession(sessionId,adminUserId); log.info("출석 세션 종료 완료: 세션ID={}", sessionId); @@ -220,8 +225,9 @@ public ResponseEntity closeSession(@PathVariable UUID sessionId,@Authentic public ResponseEntity deleteSession(@PathVariable UUID sessionId, @AuthenticationPrincipal CustomUserDetails userDetails) { log.info("출석 세션 삭제: 세션ID={}", sessionId); + UUID adminUserId = requireUserId(userDetails); - attendanceSessionService.deleteSession(sessionId,userDetails.getUserId()); + attendanceSessionService.deleteSession(sessionId,adminUserId); log.info("출석 세션 삭제 완료: 세션ID={}", sessionId); diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java index 789c2311..8ed66ce9 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java @@ -1,5 +1,7 @@ package org.sejongisc.backend.attendance.controller; +import static org.sejongisc.backend.attendance.util.AuthUserUtil.requireUserId; + import io.swagger.v3.oas.annotations.Operation; import java.util.List; import java.util.UUID; @@ -7,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.attendance.dto.SessionUserResponse; import org.sejongisc.backend.attendance.service.SessionUserService; +import org.sejongisc.backend.attendance.util.AuthUserUtil; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.user.service.UserService; import org.springframework.http.HttpStatus; @@ -17,6 +20,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -41,10 +45,12 @@ public class SessionUserController { @PostMapping("/{sessionId}/users") public ResponseEntity addUserToSession( @PathVariable UUID sessionId, - UUID userId, + @RequestParam UUID userId, @AuthenticationPrincipal CustomUserDetails userDetails) { - SessionUserResponse response = sessionUserService.addUserToSession(sessionId,userId,userDetails.getUserId()); + UUID adminUserId = requireUserId(userDetails); + + SessionUserResponse response = sessionUserService.addUserToSession(sessionId,userId,adminUserId); return ResponseEntity.status(HttpStatus.CREATED).body(response); @@ -68,8 +74,9 @@ public ResponseEntity removeUserFromSession( @PathVariable UUID userId, @AuthenticationPrincipal CustomUserDetails userDetails) { log.info("세션에서 사용자 제거: 세션ID={}, 사용자ID={}", sessionId, userId); + UUID adminUserId = requireUserId(userDetails); - sessionUserService.removeUserFromSession(sessionId, userId, userDetails.getUserId()); + sessionUserService.removeUserFromSession(sessionId, userId, adminUserId); log.info("세션에서 사용자 제거 완료: 세션ID={}, 사용자ID={}", sessionId, userId); @@ -91,8 +98,9 @@ public ResponseEntity removeUserFromSession( @GetMapping("/{sessionId}/users") public ResponseEntity> getSessionUsers(@PathVariable UUID sessionId, @AuthenticationPrincipal CustomUserDetails userDetails) { log.info("세션 참여자 조회: 세션ID={}", sessionId); + UUID adminUserId = requireUserId(userDetails); - List users = sessionUserService.getSessionUsers(sessionId, userDetails.getUserId()); + List users = sessionUserService.getSessionUsers(sessionId, adminUserId); log.info("세션 참여자 조회 완료: 세션ID={}, 참여자 수={}", sessionId, users.size()); 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 c79dc5dd..7f54117c 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 @@ -22,13 +22,9 @@ public static Permissions from(SessionRole role) { }else if(role == SessionRole.MANAGER) { return new Permissions(true, true, false); } - boolean owner = role == SessionRole.OWNER; - boolean manager = role == SessionRole.MANAGER; return new Permissions( - owner || manager, - owner || manager, - owner + true, true, true ); } 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 index 1ea8e21c..c4ee4f70 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionUser.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/SessionUser.java @@ -45,6 +45,7 @@ public class SessionUser extends BasePostgresEntity { // 세션 내 사용자 역할 @Enumerated(EnumType.STRING) + @Column(nullable = false) private SessionRole sessionRole; public void changeRole(SessionRole newRole) { diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java b/backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java new file mode 100644 index 00000000..f7568677 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java @@ -0,0 +1,14 @@ +package org.sejongisc.backend.attendance.util; + +import java.util.UUID; +import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; + +public class AuthUserUtil { + private AuthUserUtil() {} + + public static UUID requireUserId(CustomUserDetails userDetails) { + if (userDetails == null) throw new IllegalStateException("UNAUTHENTICATED"); + return userDetails.getUserId(); + } + +} From 8f7fdaa8102a8c602175b41031b3791243e6eaa2 Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Sun, 1 Feb 2026 23:31:00 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat(QR):=EC=B6=9C=EC=84=9D=EC=B2=B4?= =?UTF-8?q?=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 4 + .../config/AttendanceQuartzConfig.java | 61 ++++++ .../controller/AttendanceRoundController.java | 17 ++ .../service/AttendanceRoundService.java | 74 ++++--- .../service/AttendanceRoundStateJob.java | 32 +++ .../attendance/service/AttendanceService.java | 6 +- .../service/QrTokenStreamService.java | 192 ++++++++++++++++++ .../attendance/util/RollingQrTokenUtil.java | 114 +++++++++++ .../auth/controller/AuthCookieHelper.java | 7 +- .../common/config/swagger/SwaggerConfig.java | 3 + 10 files changed, 475 insertions(+), 35 deletions(-) create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/config/AttendanceQuartzConfig.java create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java create mode 100644 backend/src/main/java/org/sejongisc/backend/attendance/util/RollingQrTokenUtil.java diff --git a/backend/build.gradle b/backend/build.gradle index 8baebbee..fa1c3665 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -40,6 +40,7 @@ dependencies { testImplementation 'io.projectreactor:reactor-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -67,6 +68,9 @@ dependencies { implementation 'de.codecentric:spring-boot-admin-starter-server:3.5.7' implementation 'de.codecentric:spring-boot-admin-starter-client:3.5.7' + implementation 'org.springframework.boot:spring-boot-starter-quartz' + + // Actuator implementation 'org.springframework.boot:spring-boot-starter-actuator' diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/config/AttendanceQuartzConfig.java b/backend/src/main/java/org/sejongisc/backend/attendance/config/AttendanceQuartzConfig.java new file mode 100644 index 00000000..b9ce4b82 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/config/AttendanceQuartzConfig.java @@ -0,0 +1,61 @@ +package org.sejongisc.backend.attendance.config; + +import org.quartz.CronScheduleBuilder; +import org.quartz.JobBuilder; +import org.quartz.JobDetail; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.sejongisc.backend.attendance.service.AttendanceRoundStateJob; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.SpringBeanJobFactory; + +@Configuration +public class AttendanceQuartzConfig { + + public static final String ROUND_STATE_CRON = "0 0,30 * * * ?"; + + @Bean + public JobDetail attendanceRoundStateJobDetail() { + return JobBuilder.newJob(AttendanceRoundStateJob.class) + .withIdentity("attendanceRoundStateJob") + .storeDurably() + .build(); + } + + @Bean + public Trigger attendanceRoundStateTrigger(JobDetail attendanceRoundStateJobDetail) { + return TriggerBuilder.newTrigger() + .forJob(attendanceRoundStateJobDetail) + .withIdentity("attendanceRoundStateTrigger") + .withSchedule(CronScheduleBuilder.cronSchedule(ROUND_STATE_CRON)) + .build(); + } + + @Bean + public SpringBeanJobFactory springBeanJobFactory(ApplicationContext applicationContext) { + return new AutowiringSpringBeanJobFactory(applicationContext.getAutowireCapableBeanFactory()); + } + + @Bean + public SchedulerFactoryBeanCustomizer schedulerFactoryBeanCustomizer(SpringBeanJobFactory jobFactory) { + return factory -> factory.setJobFactory(jobFactory); + } + + private static class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory { + private final AutowireCapableBeanFactory beanFactory; + + private AutowiringSpringBeanJobFactory(AutowireCapableBeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + @Override + protected Object createJobInstance(org.quartz.spi.TriggerFiredBundle bundle) { + Class jobClass = bundle.getJobDetail().getJobClass(); + return beanFactory.createBean(jobClass); + } + } +} 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 4389b761..a80e21ba 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 @@ -12,8 +12,10 @@ 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.QrTokenStreamService; import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -23,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequestMapping("/api/attendance") @@ -35,6 +38,7 @@ public class AttendanceRoundController { private final AttendanceRoundService attendanceRoundService; + private final QrTokenStreamService qrTokenStreamService; /** * 라운드 생성 (관리자/OWNER) POST /api/attendance/sessions/{sessionId}/rounds @@ -98,6 +102,19 @@ public ResponseEntity issueQrToken( AttendanceRoundQrTokenResponse response = attendanceRoundService.issueQrToken(roundId, userId); return ResponseEntity.ok(response); } + /** + * QR 토큰 SSE 스트림 (관리자/OWNER) - 폴링 없이 3분마다 PUSH + * GET /api/attendance/rounds/{roundId}/qr-stream + */ + @Operation(summary = "QR 토큰 SSE 스트림", description = "폴링 없이 SSE로 3분마다 갱신되는 QR 토큰을 push합니다. (관리자/OWNER, 라운드 ACTIVE)") + @GetMapping(value = "/rounds/{roundId}/qr-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamQrToken( + @PathVariable UUID roundId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID userId = requireUserId(userDetails); + return qrTokenStreamService.subscribe(roundId, userId); + } /** * 라운드 삭제 (관리자/OWNER) DELETE /api/attendance/rounds/{roundId} 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 index d30fa4c9..8b2dfc88 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundService.java @@ -8,13 +8,15 @@ import org.sejongisc.backend.attendance.dto.AttendanceRoundQrTokenResponse; import org.sejongisc.backend.attendance.dto.AttendanceRoundRequest; import org.sejongisc.backend.attendance.dto.AttendanceRoundResponse; -import org.sejongisc.backend.attendance.entity.*; +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.sejongisc.backend.attendance.util.QrTokenUtil; +import org.sejongisc.backend.attendance.util.RollingQrTokenUtil; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,15 +27,16 @@ public class AttendanceRoundService { private static final int DEFAULT_ROUND_DURATION_HOURS = 3; - private static final long QR_TOKEN_TTL_SECONDS = 90; private final AttendanceRoundRepository attendanceRoundRepository; private final AttendanceSessionRepository attendanceSessionRepository; private final AttendanceAuthorizationService authorizationService; - /** 라운드 생성(관리자/소유자) */ + /** + * 라운드 생성(예약) - 세션 주인(OWNER)만 가능 + */ public AttendanceRoundResponse createRound(UUID sessionId, UUID userId, AttendanceRoundRequest req) { - authorizationService.ensureAdmin(sessionId, userId); + authorizationService.ensureOwner(sessionId, userId); AttendanceSession session = attendanceSessionRepository.findById(sessionId) .orElseThrow(() -> new CustomException(ErrorCode.SESSION_NOT_FOUND)); @@ -52,16 +55,15 @@ public AttendanceRoundResponse createRound(UUID sessionId, UUID userId, Attendan .closeAt(closeAt) .roundName(req.roundName()) .locationName(req.locationName()) + // 라운드마다 고유 secret (절대 클라이언트에 노출 X) .qrSecret(QrTokenUtil.generateSecret()) .build(); AttendanceRound saved = attendanceRoundRepository.save(round); - - // 목록/상세 응답에는 토큰을 넣지 않는 걸 추천(짧게 만료되므로) return AttendanceRoundResponse.from(saved, false); } - /** 라운드 개별 조회(세션 멤버만) - 토큰은 별도 API로 발급 */ + /** 라운드 개별 조회(세션 멤버만) */ @Transactional(readOnly = true) public AttendanceRoundResponse getRound(UUID roundId, UUID userId) { AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) @@ -86,7 +88,10 @@ public List getRoundsBySession(UUID sessionId, UUID use .toList(); } - /** 관리자만: QR 토큰 발급(짧게 유효) */ + /** + * (fallback) 단일 호출로 현재 3분 윈도우 QR 토큰 발급 (관리자/OWNER) + * - 폴링이 싫으면 SSE 스트림(/qr-stream) 사용 권장 + */ @Transactional(readOnly = true) public AttendanceRoundQrTokenResponse issueQrToken(UUID roundId, UUID userId) { AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) @@ -95,25 +100,28 @@ public AttendanceRoundQrTokenResponse issueQrToken(UUID roundId, UUID userId) { UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); authorizationService.ensureAdmin(sessionId, userId); - // 라운드가 ACTIVE일 때만 발급하고 싶으면 아래 체크 추가: if (round.getRoundStatus() != RoundStatus.ACTIVE) { throw new CustomException(ErrorCode.ROUND_NOT_ACTIVE); } - QrTokenUtil.IssuedToken issued = QrTokenUtil.issue(round.getRoundId(), round.getQrSecret(), QR_TOKEN_TTL_SECONDS); + RollingQrTokenUtil.IssuedToken issued = RollingQrTokenUtil.issue(round.getRoundId(), round.getQrSecret()); return new AttendanceRoundQrTokenResponse(round.getRoundId(), issued.token(), issued.expiresAtEpochSec()); } - /** 참가자 출석 처리 쪽에서 사용: 토큰 검증 후 라운드 조회 */ + /** + * 참가자 출석 처리 쪽에서 사용: + * - 토큰에서 roundId만 먼저 추출 → 라운드 조회 → ACTIVE 체크 → secret으로 서명/윈도우 검증 + */ @Transactional(readOnly = true) public AttendanceRound verifyQrTokenAndGetRound(String qrToken) { - // 토큰 파싱을 위해 roundId 먼저 뽑고 → 라운드 가져온 뒤 secret으로 검증 if (qrToken == null || qrToken.isBlank()) { throw new CustomException(ErrorCode.QR_TOKEN_MALFORMED); } String[] parts = qrToken.split(":"); - if (parts.length != 3) throw new CustomException(ErrorCode.QR_TOKEN_MALFORMED); + if (parts.length != 3) { + throw new CustomException(ErrorCode.QR_TOKEN_MALFORMED); + } UUID roundId; try { @@ -125,17 +133,22 @@ public AttendanceRound verifyQrTokenAndGetRound(String qrToken) { AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) .orElseThrow(() -> new CustomException(ErrorCode.ROUND_NOT_FOUND)); - // 라운드 상태 체크(선택이 아니라 사실상 필수) if (round.getRoundStatus() != RoundStatus.ACTIVE) { throw new CustomException(ErrorCode.ROUND_NOT_ACTIVE); } - // 서명/만료 검증 - QrTokenUtil.verifyAndParse(qrToken, round.getQrSecret()); + try { + RollingQrTokenUtil.verifyAndParse(qrToken, round.getQrSecret()); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.QR_TOKEN_MALFORMED); + } catch (IllegalStateException e) { + throw new CustomException(ErrorCode.QR_TOKEN_MALFORMED); + } + return round; } - /** 라운드 삭제(관리자/소유자) */ + /** 라운드 삭제(관리자/OWNER) */ public void deleteRound(UUID roundId, UUID userId) { AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) .orElseThrow(() -> new CustomException(ErrorCode.ROUND_NOT_FOUND)); @@ -147,7 +160,7 @@ public void deleteRound(UUID roundId, UUID userId) { log.info("라운드 삭제 완료 - roundId: {}", roundId); } - /** 라운드 마감(관리자/소유자) */ + /** 라운드 마감(관리자/OWNER) */ public void closeRound(UUID roundId, UUID userId) { AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) .orElseThrow(() -> new CustomException(ErrorCode.ROUND_NOT_FOUND)); @@ -158,7 +171,7 @@ public void closeRound(UUID roundId, UUID userId) { round.changeStatus(RoundStatus.CLOSED); } - /** 라운드 활성화(관리자/소유자) */ + /** 라운드 활성화(관리자/OWNER) */ public void openRound(UUID roundId, UUID userId) { AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) .orElseThrow(() -> new CustomException(ErrorCode.ROUND_NOT_FOUND)); @@ -169,27 +182,24 @@ public void openRound(UUID roundId, UUID userId) { round.changeStatus(RoundStatus.ACTIVE); } - @Scheduled(fixedRate = 10_000) - public void autoActivateAndCloseRounds() { + /** + * Quartz Job에서 호출: UPCOMING -> ACTIVE / ACTIVE -> CLOSED 자동 전환 + * (cron: 0분, 30분마다 실행) + */ + public void runRoundStatusMaintenance() { LocalDateTime now = LocalDateTime.now(); int closed = attendanceRoundRepository.closeDueRounds(now); int activated = attendanceRoundRepository.activateDueRounds(now); if (activated > 0 || closed > 0) { - log.info("activated={}, closed={}", activated, closed); + log.info("[Quartz] activated={}, closed={}", activated, closed); } } private void validateCreateRequest(AttendanceRoundRequest req) { - if (req.roundDate() == null) { - throw new CustomException(ErrorCode.ROUND_DATE_REQUIRED); - } - if (req.startAt() == null) { - throw new CustomException(ErrorCode.START_AT_REQUIRED); - } - if (req.roundName() == null || req.roundName().isBlank()) { - throw new CustomException(ErrorCode.ROUND_NAME_REQUIRED); - } + if (req.roundDate() == null) throw new CustomException(ErrorCode.ROUND_DATE_REQUIRED); + if (req.startAt() == null) throw new CustomException(ErrorCode.START_AT_REQUIRED); + if (req.roundName() == null || req.roundName().isBlank()) throw new CustomException(ErrorCode.ROUND_NAME_REQUIRED); if (req.closeAt() != null && !req.closeAt().isAfter(req.startAt())) { throw new CustomException(ErrorCode.END_AT_MUST_BE_AFTER_START_AT); } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java new file mode 100644 index 00000000..5ecdd17f --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java @@ -0,0 +1,32 @@ +package org.sejongisc.backend.attendance.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.sejongisc.backend.attendance.service.AttendanceRoundService; +import org.springframework.stereotype.Component; + +/** + * 라운드 상태 자동 전환(UPCOMING->ACTIVE, ACTIVE->CLOSED) Quartz Job + */ +@Component +@RequiredArgsConstructor +@Slf4j +@DisallowConcurrentExecution +public class AttendanceRoundStateJob implements Job { + + private final AttendanceRoundService attendanceRoundService; + + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + try { + attendanceRoundService.runRoundStatusMaintenance(); + } catch (Exception e) { + log.error("AttendanceRoundStateJob failed", e); + } + } +} + 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 6aa5571b..3f47b1b6 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 @@ -165,7 +165,11 @@ private AttendanceStatus parseStatus(String status) { } private boolean decideLate(AttendanceRound round, LocalDateTime checkedAt) { - var threshold = round.getStartAt().plusMinutes(5); + Integer allowedMinutes = + round.getAttendanceSession() != null ? round.getAttendanceSession().getAllowedMinutes() : null; + + long minutes = (allowedMinutes == null || allowedMinutes <= 0) ? 5L : allowedMinutes.longValue(); + var threshold = round.getStartAt().plusMinutes(minutes); return checkedAt.isAfter(threshold); } } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java new file mode 100644 index 00000000..f9f16ea1 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java @@ -0,0 +1,192 @@ +package org.sejongisc.backend.attendance.service; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.attendance.dto.AttendanceRoundQrTokenResponse; +import org.sejongisc.backend.attendance.entity.AttendanceRound; +import org.sejongisc.backend.attendance.entity.RoundStatus; +import org.sejongisc.backend.attendance.repository.AttendanceRoundRepository; +import org.sejongisc.backend.attendance.util.RollingQrTokenUtil; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * QR 토큰을 폴링 없이 서버가 PUSH 해주는 SSE 스트림 서비스. + * + * - 관리자(OWNER/MANAGER)가 QR 화면을 열면 subscribe() + * - 서버는 즉시 현재 3분 윈도우 토큰을 보내고, + * - 이후 3분 윈도우 경계마다 자동으로 새로운 토큰을 push 합니다. + * + * 참고: 프록시/로드밸런서 환경에서 SSE 연결이 idle timeout으로 끊기지 않도록 ping 이벤트를 주기적으로 보냅니다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class QrTokenStreamService { + + private static final long WINDOW_SECONDS = RollingQrTokenUtil.DEFAULT_WINDOW_SECONDS; + private static final long PING_SECONDS = 15; + + private final AttendanceRoundRepository attendanceRoundRepository; + private final AttendanceAuthorizationService authorizationService; + + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4); + + private final ConcurrentHashMap> emittersByRound = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> tokenTaskByRound = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> pingTaskByRound = new ConcurrentHashMap<>(); + + /** + * SSE 구독 (관리자/OWNER) - round가 ACTIVE가 아니면 구독 불가. + */ + public SseEmitter subscribe(UUID roundId, UUID userId) { + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) + .orElseThrow(() -> new CustomException(ErrorCode.ROUND_NOT_FOUND)); + + UUID sessionId = round.getAttendanceSession().getAttendanceSessionId(); + authorizationService.ensureAdmin(sessionId, userId); + + if (round.getRoundStatus() != RoundStatus.ACTIVE) { + throw new CustomException(ErrorCode.ROUND_NOT_ACTIVE); + } + + // timeout 0 = 무제한 (서버/프록시 설정에 따라 끊길 수 있으니 ping으로 유지) + SseEmitter emitter = new SseEmitter(0L); + + emittersByRound.computeIfAbsent(roundId, k -> new CopyOnWriteArrayList<>()).add(emitter); + + // 연결 종료 시 정리 + emitter.onCompletion(() -> removeEmitter(roundId, emitter)); + emitter.onTimeout(() -> removeEmitter(roundId, emitter)); + emitter.onError((ex) -> removeEmitter(roundId, emitter)); + + // 즉시 현재 토큰 push + try { + RollingQrTokenUtil.IssuedToken issued = RollingQrTokenUtil.issue(roundId, round.getQrSecret()); + AttendanceRoundQrTokenResponse payload = + new AttendanceRoundQrTokenResponse(roundId, issued.token(), issued.expiresAtEpochSec()); + + emitter.send(SseEmitter.event() + .name("qrToken") + .data(payload, MediaType.APPLICATION_JSON)); + } catch (IOException e) { + removeEmitter(roundId, emitter); + throw new IllegalStateException("SSE_SEND_FAILED", e); + } + + // round별 토큰 브로드캐스트 작업/핑 작업이 없으면 시작 + startRoundTasksIfAbsent(roundId); + + return emitter; + } + + private void startRoundTasksIfAbsent(UUID roundId) { + tokenTaskByRound.computeIfAbsent(roundId, rid -> { + long now = Instant.now().getEpochSecond(); + long boundary = RollingQrTokenUtil.toWindowExpiresAt(now, WINDOW_SECONDS); + long initialDelay = Math.max(0, boundary - now); // 경계 시점에 실행 + + log.info("SSE token broadcaster scheduled: roundId={}, initialDelaySec={}, periodSec={}", + rid, initialDelay, WINDOW_SECONDS); + + return scheduler.scheduleAtFixedRate(() -> broadcastNewToken(rid), initialDelay, WINDOW_SECONDS, TimeUnit.SECONDS); + }); + + pingTaskByRound.computeIfAbsent(roundId, rid -> scheduler.scheduleAtFixedRate(() -> { + var emitters = emittersByRound.get(rid); + if (emitters == null || emitters.isEmpty()) { + stopRoundTasksIfNoEmitters(rid); + return; + } + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event().name("ping").data("ok")); + } catch (Exception e) { + removeEmitter(rid, emitter); + } + } + }, PING_SECONDS, PING_SECONDS, TimeUnit.SECONDS)); + } + + private void broadcastNewToken(UUID roundId) { + CopyOnWriteArrayList emitters = emittersByRound.get(roundId); + if (emitters == null || emitters.isEmpty()) { + stopRoundTasksIfNoEmitters(roundId); + return; + } + + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId).orElse(null); + if (round == null) { + completeAll(roundId); + stopRoundTasksIfNoEmitters(roundId); + return; + } + + if (round.getRoundStatus() != RoundStatus.ACTIVE) { + // 라운드가 닫혔으면 스트림 종료 + completeAll(roundId); + stopRoundTasksIfNoEmitters(roundId); + return; + } + + RollingQrTokenUtil.IssuedToken issued = RollingQrTokenUtil.issue(roundId, round.getQrSecret()); + AttendanceRoundQrTokenResponse payload = + new AttendanceRoundQrTokenResponse(roundId, issued.token(), issued.expiresAtEpochSec()); + + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event() + .name("qrToken") + .data(payload, MediaType.APPLICATION_JSON)); + } catch (Exception e) { + removeEmitter(roundId, emitter); + } + } + } + + private void removeEmitter(UUID roundId, SseEmitter emitter) { + CopyOnWriteArrayList emitters = emittersByRound.get(roundId); + if (emitters != null) { + emitters.remove(emitter); + if (emitters.isEmpty()) { + emittersByRound.remove(roundId); + } + } + stopRoundTasksIfNoEmitters(roundId); + } + + private void completeAll(UUID roundId) { + List emitters = emittersByRound.get(roundId); + if (emitters == null) return; + for (SseEmitter e : emitters) { + try { + e.complete(); + } catch (Exception ignore) {} + } + emittersByRound.remove(roundId); + } + + private void stopRoundTasksIfNoEmitters(UUID roundId) { + CopyOnWriteArrayList emitters = emittersByRound.get(roundId); + if (emitters != null && !emitters.isEmpty()) return; + + ScheduledFuture tokenFuture = tokenTaskByRound.remove(roundId); + if (tokenFuture != null) tokenFuture.cancel(false); + + ScheduledFuture pingFuture = pingTaskByRound.remove(roundId); + if (pingFuture != null) pingFuture.cancel(false); + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/util/RollingQrTokenUtil.java b/backend/src/main/java/org/sejongisc/backend/attendance/util/RollingQrTokenUtil.java new file mode 100644 index 00000000..8f1650e4 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/attendance/util/RollingQrTokenUtil.java @@ -0,0 +1,114 @@ +package org.sejongisc.backend.attendance.util; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * 3분 단위(기본 180초)로 회전하는 QR 토큰 유틸. + * + * 검증 시에는 "현재 시각이 해당 윈도우 범위 안"(windowStart <= now < expiresAt)을 만족해야 유효. + */ +public final class RollingQrTokenUtil { + + private RollingQrTokenUtil() {} + + public static final long DEFAULT_WINDOW_SECONDS = 180; // 3분 + + // 모바일/서버 시계 오차, 네트워크 지연 약간 허용 + private static final long SKEW_SECONDS = 5; + + public record IssuedToken(String token, long expiresAtEpochSec) {} + + public record ParsedToken(UUID roundId, long expiresAtEpochSec) {} + + public static IssuedToken issue(UUID roundId, String secret) { + return issue(roundId, secret, DEFAULT_WINDOW_SECONDS); + } + + public static IssuedToken issue(UUID roundId, String secret, long windowSeconds) { + long now = Instant.now().getEpochSecond(); + long expiresAt = toWindowExpiresAt(now, windowSeconds); + String token = buildToken(roundId, secret, expiresAt); + return new IssuedToken(token, expiresAt); + } + + public static ParsedToken verifyAndParse(String token, String secret) { + return verifyAndParse(token, secret, DEFAULT_WINDOW_SECONDS); + } + + public static ParsedToken verifyAndParse(String token, String secret, long windowSeconds) { + if (token == null || token.isBlank()) { + throw new IllegalArgumentException("QR_TOKEN_MALFORMED"); + } + + String[] parts = token.split(":"); + if (parts.length != 3) { + throw new IllegalArgumentException("QR_TOKEN_MALFORMED"); + } + + UUID roundId; + long expiresAt; + try { + roundId = UUID.fromString(parts[0]); + expiresAt = Long.parseLong(parts[1]); + } catch (Exception e) { + throw new IllegalArgumentException("QR_TOKEN_MALFORMED"); + } + + String payload = roundId + ":" + expiresAt; + String expectedSig = hmacSha256Base64Url(payload, secret); + + // constant-time compare + if (!constantTimeEquals(expectedSig, parts[2])) { + throw new IllegalArgumentException("QR_TOKEN_MALFORMED"); + } + + long now = Instant.now().getEpochSecond(); + long windowStart = expiresAt - windowSeconds; + + // 유효 범위 체크(스큐 허용) + if (now < windowStart - SKEW_SECONDS || now >= expiresAt + SKEW_SECONDS) { + throw new IllegalArgumentException("QR_TOKEN_MALFORMED"); + } + + return new ParsedToken(roundId, expiresAt); + } + + /** 현재 윈도우의 끝(expiresAt) 계산 */ + public static long toWindowExpiresAt(long nowEpochSec, long windowSeconds) { + long windowStart = (nowEpochSec / windowSeconds) * windowSeconds; + return windowStart + windowSeconds; + } + + /** 특정 expiresAt 윈도우 토큰 생성 */ + public static String buildToken(UUID roundId, String secret, long expiresAtEpochSec) { + String payload = roundId + ":" + expiresAtEpochSec; + String sig = hmacSha256Base64Url(payload, secret); + return payload + ":" + sig; + } + + private static String hmacSha256Base64Url(String data, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(raw); + } catch (Exception e) { + throw new IllegalStateException("QR_TOKEN_MALFORMED", e); + } + } + + private static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) return false; + if (a.length() != b.length()) return false; + int result = 0; + for (int i = 0; i < a.length(); i++) { + result |= a.charAt(i) ^ b.charAt(i); + } + return result == 0; + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java index 87d0d63f..53f7dc0a 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java @@ -27,8 +27,11 @@ public ResponseCookie deleteCookie(String name) { private ResponseCookie createCookie(String name, String value, long maxAge) { return ResponseCookie.from(name, value) .httpOnly(true) - .secure(true) - .sameSite("None") + // 로컬에서 + .secure(false) + .sameSite("Lax") +// .secure(true) +// .sameSite("None") .path("/") .maxAge(maxAge) .build(); diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/swagger/SwaggerConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/swagger/SwaggerConfig.java index 99a71c8d..9d3db14b 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/swagger/SwaggerConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/swagger/SwaggerConfig.java @@ -2,8 +2,10 @@ import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -40,3 +42,4 @@ public OpenAPI openAPI() { } + From 7a945a04df2bc193a847897e0aeb187915ba4c64 Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Fri, 6 Feb 2026 19:48:52 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix(=EC=B6=9C=EC=B2=B5):=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=BC=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/entity/AttendanceStatus.java | 1 - .../service/AttendanceRoundStateJob.java | 2 +- .../service/QrTokenStreamService.java | 31 +++++++++++++++++++ .../auth/controller/AuthCookieHelper.java | 8 ++--- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceStatus.java b/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceStatus.java index 6cba3a7c..8a746594 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceStatus.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/entity/AttendanceStatus.java @@ -11,6 +11,5 @@ public enum AttendanceStatus { LATE("지각"), // 지각 출석 ABSENT("결석"), // 미출석 EXCUSED("사유결석"); // 사전 허가된 결석 - private final String description; } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java index 5ecdd17f..e1a14d0c 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceRoundStateJob.java @@ -10,7 +10,7 @@ import org.springframework.stereotype.Component; /** - * 라운드 상태 자동 전환(UPCOMING->ACTIVE, ACTIVE->CLOSED) Quartz Job + * 라운드 상태 자동 전환(UPCOMING->ACTIVE, ACTIVE->CLOSED) */ @Component @RequiredArgsConstructor diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java index f9f16ea1..98916274 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java @@ -1,5 +1,6 @@ package org.sejongisc.backend.attendance.service; +import jakarta.annotation.PreDestroy; import java.io.IOException; import java.time.Instant; import java.util.List; @@ -189,4 +190,34 @@ private void stopRoundTasksIfNoEmitters(UUID roundId) { ScheduledFuture pingFuture = pingTaskByRound.remove(roundId); if (pingFuture != null) pingFuture.cancel(false); } + + + @PreDestroy + public void cleanup() { + // SSE 연결 종료 (원하면) + emittersByRound.forEach((rid, list) -> { + for (SseEmitter e : list) { + try { e.complete(); } catch (Exception ignore) {} + } + }); + emittersByRound.clear(); + + // 예약 작업 취소 + tokenTaskByRound.values().forEach(f -> f.cancel(false)); + pingTaskByRound.values().forEach(f -> f.cancel(false)); + tokenTaskByRound.clear(); + pingTaskByRound.clear(); + + // 스레드풀 종료 + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java index 53f7dc0a..0b885b94 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java @@ -28,10 +28,10 @@ private ResponseCookie createCookie(String name, String value, long maxAge) { return ResponseCookie.from(name, value) .httpOnly(true) // 로컬에서 - .secure(false) - .sameSite("Lax") -// .secure(true) -// .sameSite("None") +// .secure(false) +// .sameSite("Lax") + .secure(true) + .sameSite("None") .path("/") .maxAge(maxAge) .build(); From 2be35e24904183c8a31e856625d13ce3aef4585a Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Fri, 6 Feb 2026 20:07:57 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix(=EC=B6=9C=EC=B2=B5)=20:=20=EC=82=AC?= =?UTF-8?q?=EC=86=8C=ED=95=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/QrTokenStreamService.java | 68 +++++++++++-------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java index 98916274..e6458efa 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java @@ -28,8 +28,8 @@ * QR 토큰을 폴링 없이 서버가 PUSH 해주는 SSE 스트림 서비스. * * - 관리자(OWNER/MANAGER)가 QR 화면을 열면 subscribe() - * - 서버는 즉시 현재 3분 윈도우 토큰을 보내고, - * - 이후 3분 윈도우 경계마다 자동으로 새로운 토큰을 push 합니다. + * - 서버는 즉시 현재 윈도우 토큰을 보내고, + * - 이후 윈도우 경계마다 자동으로 새로운 토큰을 push 합니다. * * 참고: 프록시/로드밸런서 환경에서 SSE 연결이 idle timeout으로 끊기지 않도록 ping 이벤트를 주기적으로 보냅니다. */ @@ -103,21 +103,33 @@ private void startRoundTasksIfAbsent(UUID roundId) { log.info("SSE token broadcaster scheduled: roundId={}, initialDelaySec={}, periodSec={}", rid, initialDelay, WINDOW_SECONDS); - return scheduler.scheduleAtFixedRate(() -> broadcastNewToken(rid), initialDelay, WINDOW_SECONDS, TimeUnit.SECONDS); + // scheduleAtFixedRate 내부 예외 발생 시 태스크가 영구 중단될 수 있으니 최상위 try-catch로 보호 + return scheduler.scheduleAtFixedRate(() -> { + try { + broadcastNewToken(rid); + } catch (Exception ex) { + log.error("Token broadcast failed: roundId={}", rid, ex); + } + }, initialDelay, WINDOW_SECONDS, TimeUnit.SECONDS); }); pingTaskByRound.computeIfAbsent(roundId, rid -> scheduler.scheduleAtFixedRate(() -> { - var emitters = emittersByRound.get(rid); - if (emitters == null || emitters.isEmpty()) { - stopRoundTasksIfNoEmitters(rid); - return; - } - for (SseEmitter emitter : emitters) { - try { - emitter.send(SseEmitter.event().name("ping").data("ok")); - } catch (Exception e) { - removeEmitter(rid, emitter); + // ping 태스크도 최상위 try-catch로 보호 + try { + var emitters = emittersByRound.get(rid); + if (emitters == null || emitters.isEmpty()) { + stopRoundTasksIfNoEmitters(rid); + return; } + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event().name("ping").data("ok")); + } catch (Exception e) { + removeEmitter(rid, emitter); + } + } + } catch (Exception ex) { + log.error("Ping task failed: roundId={}", rid, ex); } }, PING_SECONDS, PING_SECONDS, TimeUnit.SECONDS)); } @@ -159,25 +171,24 @@ private void broadcastNewToken(UUID roundId) { } private void removeEmitter(UUID roundId, SseEmitter emitter) { - CopyOnWriteArrayList emitters = emittersByRound.get(roundId); - if (emitters != null) { + emittersByRound.computeIfPresent(roundId, (key, emitters) -> { emitters.remove(emitter); - if (emitters.isEmpty()) { - emittersByRound.remove(roundId); - } - } + return emitters.isEmpty() ? null : emitters; + }); + stopRoundTasksIfNoEmitters(roundId); } private void completeAll(UUID roundId) { - List emitters = emittersByRound.get(roundId); - if (emitters == null) return; - for (SseEmitter e : emitters) { + List removed = emittersByRound.remove(roundId); + if (removed == null) return; + + for (SseEmitter e : removed) { try { e.complete(); - } catch (Exception ignore) {} + } catch (Exception ignore) { + } } - emittersByRound.remove(roundId); } private void stopRoundTasksIfNoEmitters(UUID roundId) { @@ -191,13 +202,15 @@ private void stopRoundTasksIfNoEmitters(UUID roundId) { if (pingFuture != null) pingFuture.cancel(false); } - @PreDestroy public void cleanup() { - // SSE 연결 종료 (원하면) + // SSE 연결 종료 emittersByRound.forEach((rid, list) -> { for (SseEmitter e : list) { - try { e.complete(); } catch (Exception ignore) {} + try { + e.complete(); + } catch (Exception ignore) { + } } }); emittersByRound.clear(); @@ -219,5 +232,4 @@ public void cleanup() { Thread.currentThread().interrupt(); } } - } From a2f796d8ecf22bc55ce2e1dd30ad40e38ddfec06 Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Sat, 21 Feb 2026 23:20:14 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat(=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8?= =?UTF-8?q?=20=EC=B4=88=EA=B8=B0=ED=99=94):=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/auth/service/EmailService.java | 18 +- .../common/config/EmailProperties.java | 1 + .../config/security/SecurityConstants.java | 4 +- .../backend/common/exception/ErrorCode.java | 6 + .../user/controller/UserController.java | 53 ++- ....java => PasswordResetConfirmRequest.java} | 15 +- .../user/dto/PasswordResetSendRequest.java | 4 +- .../user/repository/UserRepository.java | 2 + .../backend/user/service/UserService.java | 69 ++-- .../backend/user/service/UserServiceTest.java | 306 ++++++------------ 10 files changed, 232 insertions(+), 246 deletions(-) rename backend/src/main/java/org/sejongisc/backend/user/dto/{PasswordResetVerifyRequest.java => PasswordResetConfirmRequest.java} (76%) diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java index 05dbd20f..ca80fa1b 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java @@ -15,6 +15,7 @@ import org.sejongisc.backend.user.repository.UserRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.mail.MailException; import org.springframework.mail.MailSendException; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; @@ -129,28 +130,21 @@ private String generateCode() { // 비밀번호 인증 관련 메서드 public void sendResetEmail(String email) { - if(!userRepository.existsByEmail(email)){ - log.debug("Password reset requested for non-existent email {}", email); - return; - } String code = generateCode(); - redisTemplate.opsForValue().set("PASSWORD_RESET_EMAIL:" + email, code, emailProperties.getCodeExpire()); + String key = emailProperties.getKeyPrefix().getReset() + email; + redisTemplate.opsForValue().set(key, code, emailProperties.getCodeExpire()); + try { MimeMessage message = createResetMessage(email, code); mailSender.send(message); - } catch (MessagingException e) { + } catch (MessagingException | MailException e) { + redisTemplate.delete(key); throw new MailSendException("failed to send mail", e); } } - public void verifyResetEmail(String email, String code) { - String stored = redisTemplate.opsForValue().get("PASSWORD_RESET_EMAIL:" + email); - if (stored == null) throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND); - if(!stored.equals(code)) throw new CustomException(ErrorCode.EMAIL_CODE_MISMATCH); - redisTemplate.delete("PASSWORD_RESET_EMAIL:" + email); - } private MimeMessage createResetMessage(String email, String code) throws MessagingException { MimeMessage message = mailSender.createMimeMessage(); diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java b/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java index c2b8762f..cc28ddf1 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java @@ -21,6 +21,7 @@ public class EmailProperties { public static class KeyPrefix { private String verify; private String verified; + private String reset; } @Setter diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java index 565e8b4c..1fd4dbb1 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java @@ -18,7 +18,9 @@ public class SecurityConstants { "/login/**", "/oauth2/**", "/favicon.ico", - "/error" + "/error", + "/api/user/password/reset/confirm", + "/api/user/password/reset/send" }; public static final String[] ADMIN_ONLY_URLS = { diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index fc668015..059e59b0 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -77,6 +77,10 @@ public enum ErrorCode { EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST, "24시간 이내에 이미 인증된 이메일입니다."), + RESET_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "비밀번호 재설정 코드가 만료되었습니다."), + + INVALID_RESET_CODE(HttpStatus.BAD_REQUEST, "유효하지 않은 비밀번호 재설정 코드입니다."), + // QUANTBOT EXECUTION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 퀀트봇 실행 내역이 존재하지 않습니다."), @@ -90,6 +94,8 @@ public enum ErrorCode { DUPLICATE_USER(HttpStatus.CONFLICT, "이미 가입된 사용자입니다."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."), + + // BETTING STOCK_NOT_FOUND(HttpStatus.NOT_FOUND, "주식 종목이 존재하지 않습니다."), diff --git a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java index 7c1616f3..8a4092bb 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java +++ b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java @@ -70,11 +70,58 @@ public ResponseEntity findUserID(@RequestBody @Valid UserIdFindRequest reques } */ - // TODO : 비밀번호 재설정 시 학번 입력 고려 - @Operation(summary = "비밀번호 재설정 : 이메일로 인증코드를 전송합니다.") + @Operation( + summary = "비밀번호 재설정 : 이메일로 인증코드를 전송합니다.", + description = """ + + ## 인증(JWT): **불필요** + + ## 요청 바디 ( `PasswordResetSendRequest` ) + - **`email`**: 가입된 이메일 + - **`studentId`**: 가입된 학번 + + ## 동작 설명 + - 입력한 이메일 + 학번으로 사용자를 확인합니다. + - 일치하는 사용자가 있으면 인증코드를 생성합니다. + - 인증코드를 Redis에 일정 시간 저장합니다. (TTL 적용) + - 해당 이메일로 인증코드를 전송합니다. + + ## 반환값 + - 성공 메시지 (`인증코드를 전송했습니다.`) + """) @PostMapping("/password/reset/send") public ResponseEntity sendReset(@RequestBody @Valid PasswordResetSendRequest req){ - userService.passwordReset(req.email().trim()); + userService.passwordResetSendCode(req); return ResponseEntity.ok(Map.of("message", "인증코드를 전송했습니다.")); } + + @Operation( + summary = "비밀번호 재설정 : 인증코드와 새 비밀번호를 입력받아, 비밀번호를 변경합니다.", + description = """ + + ## 인증(JWT): **불필요** + + + ## 요청 파라미터 + - **`code`**: 이메일로 받은 인증코드 + - **`newPassword`**: 새 비밀번호 + + ## 요청 바디 ( `PasswordResetSendRequest` ) + - **`email`**: 가입된 이메일 + - **`studentId`**: 가입된 학번 + + ## 동작 설명 + - 이메일 + 학번으로 사용자를 다시 확인합니다. + - Redis에 저장된 인증코드와 입력한 `code`를 비교합니다. + - 인증코드가 일치하면 새 비밀번호를 정책 검증 후 암호화하여 저장합니다. + - 사용한 인증코드는 Redis에서 삭제합니다. (1회성) + + ## 반환값 + - 성공 메시지 (`비밀번호가 변경되었습니다.`) + """) + @PostMapping("/password/reset/confirm") + public ResponseEntity confirmReset(@RequestParam String code, @RequestParam String newPassword ,@RequestBody @Valid PasswordResetSendRequest req){ + userService.resetPasswordByCode(code, newPassword,req); + return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다.")); + } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetConfirmRequest.java similarity index 76% rename from backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java rename to backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetConfirmRequest.java index d8407e18..a97f847b 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetConfirmRequest.java @@ -5,7 +5,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -public record PasswordResetVerifyRequest( +public record PasswordResetConfirmRequest( @Schema( example = "testuser@example.com", @@ -21,5 +21,16 @@ public record PasswordResetVerifyRequest( ) @NotBlank(message = "인증코드는 필수입니다.") @Size(min = 6, max = 6, message = "인증코드는 6자리여야 합니다.") - String code + String code, + + @NotBlank(message = "학번은 필수입니다.") + String studentId, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + String newPassword + + + + + ) {} diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java index 79fe5861..b5d7a686 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java @@ -12,5 +12,7 @@ public record PasswordResetSendRequest( ) @NotBlank(message = "이메일은 필수입니다.") @Email(message = "올바른 이메일 형식이 아닙니다.") - String email + String email, + @NotBlank(message = "학번은 필수입니다.") + String studentId ) { } diff --git a/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java b/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java index 56e366a6..28baf381 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java @@ -13,9 +13,11 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); boolean existsByPhoneNumber(String phoneNumber); boolean existsByEmailOrStudentId(String email, String studentId); + boolean existsByEmailAndStudentId(String email, String studentId); boolean existsByStudentId(String studentId); Optional findUserByEmail(String email); + Optional findByEmailAndStudentId(String email, String studentId); @Query( "SELECT u FROM User u " + diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java index 7fff227b..17e0f633 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java @@ -7,6 +7,7 @@ import org.sejongisc.backend.common.auth.service.EmailService; import org.sejongisc.backend.common.auth.service.RefreshTokenService; import org.sejongisc.backend.common.annotation.OptimisticRetry; +import org.sejongisc.backend.common.config.EmailProperties; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.point.dto.AccountEntry; @@ -15,6 +16,7 @@ import org.sejongisc.backend.point.entity.TransactionReason; import org.sejongisc.backend.point.service.AccountService; import org.sejongisc.backend.point.service.PointLedgerService; +import org.sejongisc.backend.user.dto.PasswordResetSendRequest; import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.entity.User; import org.sejongisc.backend.user.entity.UserStatus; @@ -39,10 +41,11 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final EmailService emailService; - private final RedisTemplate redisTemplate; + private final RedisTemplate redisTemplate; private final RefreshTokenService refreshTokenService; private final AccountService accountService; private final PointLedgerService pointLedgerService; + private final EmailProperties emailProperties; // --- 핵심 회원 서비스 --- @@ -98,43 +101,64 @@ public void updateUser(UUID userId, UserUpdateRequest request) { log.info("회원 정보 수정 완료: userId={}", userId); } - public void passwordReset(String email) { - String nEmail = validateNotBlank(email, "이메일"); + public void passwordResetSendCode(PasswordResetSendRequest req) { + String nEmail = validateNotBlank(req.email(), "이메일").trim(); + String nStudentId = validateNotBlank(req.studentId(), "학번").trim(); - if (!userRepository.existsByEmail(nEmail)) { - log.debug("존재하지 않는 이메일로 비밀번호 재설정 요청: {}", nEmail); - return; + if (!userRepository.existsByEmailAndStudentId(nEmail, nStudentId)) { + log.debug("이메일과 학번 불일치: email={}, studentId={}", nEmail, nStudentId); + throw new CustomException(ErrorCode.USER_NOT_FOUND); } - emailService.sendResetEmail(nEmail); } - public String verifyResetCodeAndIssueToken(String email, String code) { - String nEmail = validateNotBlank(email, "이메일"); - String nCode = validateNotBlank(code, "인증코드"); + @Transactional + public void resetPasswordByCode(String resetCode, String newPassword, PasswordResetSendRequest req) { - emailService.verifyResetEmail(nEmail, nCode); + String email = validateNotBlank(req.email(), "이메일").trim(); + String studentId = validateNotBlank(req.studentId(), "학번").trim(); + String inputCode = validateNotBlank(resetCode, "인증코드").trim(); - String token = UUID.randomUUID().toString(); - saveResetTokenToRedis(token, nEmail); + // 사용자 조회 (이메일+학번 같이 확인하는 게 안전) + User user = userRepository.findByEmailAndStudentId(email, studentId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - return token; - } - @Transactional - public void resetPasswordByToken(String resetToken, String newPassword) { - String email = getEmailFromRedis(resetToken); - User user = userRepository.findUserByEmail(email) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(newPassword); + // Redis에서 인증코드 조회 + String redisKey = emailProperties.getKeyPrefix().getReset() + email; + + Long ttl = redisTemplate.getExpire(redisKey, java.util.concurrent.TimeUnit.SECONDS); + String savedCode = redisTemplate.opsForValue().get(redisKey); + + log.info("RESET CODE CHECK key={}, inputCode={}, savedCode={}, ttlSec={}", + redisKey, inputCode, savedCode, ttl); + + + if (savedCode == null) { + throw new CustomException(ErrorCode.RESET_CODE_EXPIRED); + } + + + + if (!savedCode.equals(inputCode)) { + throw new CustomException(ErrorCode.INVALID_RESET_CODE); + } + + // 새 비밀번호 검증 + 암호화 저장 + String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(newPassword); user.setPasswordHash(passwordEncoder.encode(trimmedPassword)); - deleteResetTokenFromRedis(resetToken); + // 인증코드 1회성 처리 (삭제) + redisTemplate.delete(redisKey); + + // 기존 로그인 토큰 무효화 refreshTokenService.deleteByUserId(user.getUserId()); } + + public List findAllUsersMissingAccount() { return userRepository.findAllUsersMissingAccount(); } @@ -143,6 +167,7 @@ public List findAllUsersMissingAccount() { private String validateNotBlank(String value, String fieldName) { if (value == null || value.trim().isEmpty()) { + log.debug(fieldName); throw new CustomException(ErrorCode.INVALID_INPUT); } return value.trim(); diff --git a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java index fa8792aa..d5d2832d 100644 --- a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java @@ -132,40 +132,40 @@ void signup_duplicateEmail_throws() { verifyNoInteractions(passwordEncoder); } - @Test - @DisplayName("회원가입: Role이 null이면 기본값 MEMBER로 저장") - void signup_nullRole_defaultsToMember() { - // given - SignupRequest req = SignupRequest.builder() - .name("이몽룡") - .email("lee@example.com") - .password("Secret!234") - .role(null) // null 전달 - .phoneNumber("01099998888") - .build(); - - when(userRepository.existsByEmail(req.getEmail())).thenReturn(false); - when(passwordEncoder.encode(req.getPassword())).thenReturn("ENC_PW"); - - UUID id = UUID.randomUUID(); - LocalDateTime now = LocalDateTime.now(); - - when(userRepository.save(any(User.class))).thenAnswer(inv -> { - User u = inv.getArgument(0, User.class); - u.setUserId(id); - u.setCreatedDate(now); - u.setUpdatedDate(now); - // 서비스에서 기본값을 TEAM_MEMBER로 세팅한다고 가정 - assertThat(u.getRole()).isEqualTo(Role.TEAM_MEMBER); - return u; - }); - - // when - SignupResponse res = userService.signup(req); - - // then - assertThat(res.getRole()).isEqualTo(Role.TEAM_MEMBER); - } +// @Test +// @DisplayName("회원가입: Role이 null이면 기본값 MEMBER로 저장") +// void signup_nullRole_defaultsToMember() { +// // given +// SignupRequest req = SignupRequest.builder() +// .name("이몽룡") +// .email("lee@example.com") +// .password("Secret!234") +// .role(null) // null 전달 +// .phoneNumber("01099998888") +// .build(); +// +// when(userRepository.existsByEmail(req.getEmail())).thenReturn(false); +// when(passwordEncoder.encode(req.getPassword())).thenReturn("ENC_PW"); +// +// UUID id = UUID.randomUUID(); +// LocalDateTime now = LocalDateTime.now(); +// +// when(userRepository.save(any(User.class))).thenAnswer(inv -> { +// User u = inv.getArgument(0, User.class); +// u.setUserId(id); +// u.setCreatedDate(now); +// u.setUpdatedDate(now); +// // 서비스에서 기본값을 TEAM_MEMBER로 세팅한다고 가정 +// assertThat(u.getRole()).isEqualTo(Role.TEAM_MEMBER); +// return u; +// }); +// +// // when +// SignupResponse res = userService.signup(req); +// +// // then +// assertThat(res.getRole()).isEqualTo(Role.TEAM_MEMBER); +// } @Test @DisplayName("회원가입 실패: 전화번호 중복이면 CustomException(DUPLICATE_PHONE)") @@ -220,73 +220,73 @@ void signup_dataIntegrityViolation_throws() { assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.DUPLICATE_USER); } - @Test - @DisplayName("OAuth 로그인: 기존 계정이 있으면 해당 User 반환") - void findOrCreateUser_existingUser() { - // given - OauthUserInfo mockInfo = new OauthUserInfo() { - @Override public AuthProvider getProvider() { return AuthProvider.GOOGLE; } - @Override public String getProviderUid() { return "google-123"; } - @Override public String getName() { return "홍길동"; } - @Override public String getAccessToken() { return "mock-access-token"; } - }; - - User existingUser = User.builder() - .userId(UUID.randomUUID()) - .name("홍길동") - .role(Role.TEAM_MEMBER) - .build(); - - UserOauthAccount account = UserOauthAccount.builder() - .user(existingUser) - .provider(AuthProvider.GOOGLE) - .providerUid("google-123") - .build(); - - when(oauthAccountRepository.findByProviderAndProviderUid(AuthProvider.GOOGLE, "google-123")) - .thenReturn(Optional.of(account)); - - // when - User result = userService.findOrCreateUser(mockInfo); - - // then - assertThat(result).isSameAs(existingUser); - verify(oauthAccountRepository).findByProviderAndProviderUid(AuthProvider.GOOGLE, "google-123"); - verifyNoMoreInteractions(userRepository); // 새 저장 안 함 - } - - @Test - @DisplayName("OAuth 로그인: 기존 계정이 없으면 새 User + UserOauthAccount 생성") - void findOrCreateUser_newUser() { - // given - OauthUserInfo mockInfo = new OauthUserInfo() { - @Override public AuthProvider getProvider() { return AuthProvider.KAKAO; } - @Override public String getProviderUid() { return "kakao-999"; } - @Override public String getName() { return "카카오유저"; } - @Override public String getAccessToken() { return "mock-access-token"; } - }; - - when(oauthAccountRepository.findByProviderAndProviderUid(AuthProvider.KAKAO, "kakao-999")) - .thenReturn(Optional.empty()); - - User newUser = User.builder() - .userId(UUID.randomUUID()) - .name("카카오유저") - .role(Role.TEAM_MEMBER) - .build(); - - when(userRepository.save(any(User.class))).thenReturn(newUser); - - // when - User result = userService.findOrCreateUser(mockInfo); - - // then - assertThat(result.getName()).isEqualTo("카카오유저"); - assertThat(result.getRole()).isEqualTo(Role.TEAM_MEMBER); - - verify(userRepository).save(any(User.class)); - verify(oauthAccountRepository).save(any(UserOauthAccount.class)); - } +// @Test +// @DisplayName("OAuth 로그인: 기존 계정이 있으면 해당 User 반환") +// void findOrCreateUser_existingUser() { +// // given +// OauthUserInfo mockInfo = new OauthUserInfo() { +// @Override public AuthProvider getProvider() { return AuthProvider.GOOGLE; } +// @Override public String getProviderUid() { return "google-123"; } +// @Override public String getName() { return "홍길동"; } +// @Override public String getAccessToken() { return "mock-access-token"; } +// }; +// +// User existingUser = User.builder() +// .userId(UUID.randomUUID()) +// .name("홍길동") +// .role(Role.TEAM_MEMBER) +// .build(); +// +// UserOauthAccount account = UserOauthAccount.builder() +// .user(existingUser) +// .provider(AuthProvider.GOOGLE) +// .providerUid("google-123") +// .build(); +// +// when(oauthAccountRepository.findByProviderAndProviderUid(AuthProvider.GOOGLE, "google-123")) +// .thenReturn(Optional.of(account)); +// +// // when +// User result = userService.findOrCreateUser(mockInfo); +// +// // then +// assertThat(result).isSameAs(existingUser); +// verify(oauthAccountRepository).findByProviderAndProviderUid(AuthProvider.GOOGLE, "google-123"); +// verifyNoMoreInteractions(userRepository); // 새 저장 안 함 +// } +// +// @Test +// @DisplayName("OAuth 로그인: 기존 계정이 없으면 새 User + UserOauthAccount 생성") +// void findOrCreateUser_newUser() { +// // given +// OauthUserInfo mockInfo = new OauthUserInfo() { +// @Override public AuthProvider getProvider() { return AuthProvider.KAKAO; } +// @Override public String getProviderUid() { return "kakao-999"; } +// @Override public String getName() { return "카카오유저"; } +// @Override public String getAccessToken() { return "mock-access-token"; } +// }; +// +// when(oauthAccountRepository.findByProviderAndProviderUid(AuthProvider.KAKAO, "kakao-999")) +// .thenReturn(Optional.empty()); +// +// User newUser = User.builder() +// .userId(UUID.randomUUID()) +// .name("카카오유저") +// .role(Role.TEAM_MEMBER) +// .build(); +// +// when(userRepository.save(any(User.class))).thenReturn(newUser); +// +// // when +// User result = userService.findOrCreateUser(mockInfo); +// +// // then +// assertThat(result.getName()).isEqualTo("카카오유저"); +// assertThat(result.getRole()).isEqualTo(Role.TEAM_MEMBER); +// +// verify(userRepository).save(any(User.class)); +// verify(oauthAccountRepository).save(any(UserOauthAccount.class)); +// } @@ -336,115 +336,11 @@ void updateUser_allFieldsNull_noChanges() { verifyNoInteractions(passwordEncoder); // 비밀번호 인코더 안 씀 } - @Test - @DisplayName("비밀번호 재설정 요청: 유효한 이메일이면 인증 메일 전송") - void passwordReset_success() { - // given - String email = "user@example.com"; - User mockUser = User.builder().email(email).build(); - - when(userRepository.findUserByEmail(email)).thenReturn(Optional.of(mockUser)); - // when - userService.passwordReset(email); - // then - verify(emailService, times(1)).sendResetEmail(email); - } - @Test - @DisplayName("비밀번호 재설정 요청 실패: 존재하지 않는 이메일이면 예외 발생") - void passwordReset_userNotFound() { - // given - String email = "notfound@example.com"; - when(userRepository.findUserByEmail(email)).thenReturn(Optional.empty()); - // when & then - CustomException ex = assertThrows(CustomException.class, () -> userService.passwordReset(email)); - assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND); - verify(emailService, never()).sendResetEmail(anyString()); - } - @Test - @DisplayName("비밀번호 재설정 코드 검증 성공: Redis에 토큰 저장 후 반환") - void verifyResetCodeAndIssueToken_success() { - // given - String email = "user@example.com"; - String code = "123456"; - - doNothing().when(emailService).verifyResetEmail(email, code); - when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class)); - - // when - String token = userService.verifyResetCodeAndIssueToken(email, code); - - // then - assertThat(token).isNotNull(); - verify(emailService).verifyResetEmail(email, code); - verify(redisTemplate.opsForValue(), atLeastOnce()) - .set(startsWith("PASSWORD_RESET:"), eq(email), any(Duration.class)); - } - - @Test - @DisplayName("비밀번호 재설정: 토큰 유효 시 비밀번호 변경 및 RefreshToken 삭제") - void resetPasswordByToken_success() { - // given - String resetToken = "abc123"; - String email = "user@example.com"; - String newPassword = "newPassword!"; - var valueOps = mock(org.springframework.data.redis.core.ValueOperations.class); - when(redisTemplate.opsForValue()).thenReturn(valueOps); - when(valueOps.get("PASSWORD_RESET:" + resetToken)).thenReturn(email); - - User user = User.builder() - .userId(UUID.randomUUID()) - .email(email) - .passwordHash("OLD_HASH") - .build(); - - when(userRepository.findUserByEmail(email)).thenReturn(Optional.of(user)); - when(passwordEncoder.encode(newPassword)).thenReturn("ENCODED_NEW_PW"); - - // when - userService.resetPasswordByToken(resetToken, newPassword); - - // then - assertThat(user.getPasswordHash()).isEqualTo("ENCODED_NEW_PW"); - verify(passwordEncoder).encode(newPassword); - verify(userRepository).save(user); - verify(refreshTokenService).deleteByUserId(user.getUserId()); - verify(redisTemplate).delete("PASSWORD_RESET:" + resetToken); - } - - @Test - @DisplayName("비밀번호 재설정 실패: Redis에서 이메일을 찾지 못하면 예외 발생") - void resetPasswordByToken_invalidToken_throws() { - // given - String resetToken = "invalid"; - when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class)); - when(redisTemplate.opsForValue().get("PASSWORD_RESET:" + resetToken)).thenReturn(null); - - // when - CustomException ex = assertThrows(CustomException.class, - () -> userService.resetPasswordByToken(resetToken, "newPw")); - - // then - assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.EMAIL_CODE_NOT_FOUND); - verify(userRepository, never()).save(any()); - } - - @Test - void verifyResetCodeAndIssueToken_RedisFailure_ThrowsException() { - String email = "test@example.com"; - String code = "123456"; - - doThrow(new RuntimeException("Redis down")) - .when(redisTemplate.opsForValue()) - .set(anyString(), any(), any(Duration.class)); - - assertThrows(CustomException.class, - () -> userService.verifyResetCodeAndIssueToken(email, code)); - } } From e31a2706db82437f9e79b7dc9a6f69a353b3851b Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Sat, 21 Feb 2026 23:20:57 +0900 Subject: [PATCH 08/14] =?UTF-8?q?chore(=EC=B6=9C=EC=B2=B5):=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=B6=80=EA=B0=80=EC=84=A4=EB=AA=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #221 --- .../controller/AttendanceController.java | 30 ++++++++++++++++++- .../controller/AttendanceRoundController.java | 26 +++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) 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 7b5c0ee7..33c471fd 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 @@ -58,7 +58,35 @@ public ResponseEntity> getAttendancesByRound( * 라운드 내 특정 유저 출석 상태 수정(관리자/OWNER) * PUT /api/attendance/rounds/{roundId}/users/{userId} */ - @Operation(summary = "출석 상태 수정", description = "특정 라운드에서 특정 유저의 출석 상태를 수정합니다. (관리자/OWNER)") + @Operation( + summary = "출석 상태 수정", + description = """ + + ## 인증(JWT): **필요** + + + ## 권한 + - **세션 관리자 / OWNER** + + ## 경로 파라미터 + - **`roundId`**: 출석 상태를 수정할 라운드 ID (`UUID`) + - **`userId`**: 출석 상태를 수정할 대상 사용자 ID (`UUID`) + + ## 요청 바디 ( `AttendanceStatusUpdateRequest` ) + - **`status`**: 출석 상태 (필수) + - 허용값 예시: `PRESENT`, `LATE`, `ABSENT`, `EXCUSED` + - **`reason`**: 상태 수정 사유 (선택) + - 예: 지각 사유, 공결 사유 등 + + ## 동작 설명 + - 특정 라운드에서 특정 사용자의 출석 상태를 수정합니다. + - 요청한 사용자가 해당 세션의 관리자/OWNER인지 검증합니다. + - `status` 값과 `reason` 값을 기반으로 출석 상태를 반영합니다. + + ## 응답 + - **200 OK** + - 수정된 출석 정보 (`AttendanceResponse`) + """) @PutMapping("/rounds/{roundId}/users/{userId}") public ResponseEntity updateAttendanceStatus( @PathVariable UUID roundId, 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 a80e21ba..b067d97f 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 @@ -43,7 +43,31 @@ public class AttendanceRoundController { /** * 라운드 생성 (관리자/OWNER) POST /api/attendance/sessions/{sessionId}/rounds */ - @Operation(summary = "라운드 생성", description = "세션에 새로운 출석 라운드를 생성합니다. (관리자/OWNER)") + @Operation(summary = "라운드 생성", + description = """ + + ## 인증(JWT): **필요** + + + ## 권한 + - **세션 관리자 / OWNER** + + ## 경로 파라미터 + - **`sessionId`**: 라운드를 생성할 출석 세션 ID (`UUID`) + + ## 요청 바디 ( `AttendanceRoundRequest` ) + - **`roundDate`**: 라운드 날짜 (`yyyy-MM-dd`) + - **`startAt`**: 출석 시작 시간 (`yyyy-MM-dd'T'HH:mm:ss`) + - **`closeAt`**: 출석 마감 시간 (`yyyy-MM-dd'T'HH:mm:ss`) + - 선택값 (null 가능) + - null이면 서버에서 자동 계산하도록 구현할 수 있습니다. + - **`roundName`**: 라운드 이름 (예: 1주차, OT 출석) + - **`locationName`**: 출석 위치명 (예: 공학관 301호) + + ## 동작 설명 + - 지정한 세션에 새로운 출석 라운드를 생성합니다. + - 요청한 사용자가 해당 세션의 관리자/OWNER인지 검증합니다. + """) @PostMapping("/sessions/{sessionId}/rounds") public ResponseEntity createRound( @PathVariable UUID sessionId, From 99bc72eecb8d555176c0e427598a907aa3773de8 Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Sat, 21 Feb 2026 23:35:47 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix(=EB=B3=B4=EC=95=88)=20:=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=EA=B4=80=EB=A0=A8=20=EC=82=AC=ED=95=AD=20=EC=88=A8?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/common/auth/service/EmailService.java | 3 +++ .../backend/user/controller/UserController.java | 4 ++-- .../user/dto/PasswordResetConfirmRequest.java | 5 +++++ .../sejongisc/backend/user/service/UserService.java | 13 ++++--------- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java index ca80fa1b..511c9d5d 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java @@ -133,6 +133,9 @@ public void sendResetEmail(String email) { String code = generateCode(); String key = emailProperties.getKeyPrefix().getReset() + email; + if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { + redisTemplate.delete(key); + } redisTemplate.opsForValue().set(key, code, emailProperties.getCodeExpire()); diff --git a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java index 8a4092bb..3d3afdee 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java +++ b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java @@ -120,8 +120,8 @@ public ResponseEntity sendReset(@RequestBody @Valid PasswordResetSendRequest - 성공 메시지 (`비밀번호가 변경되었습니다.`) """) @PostMapping("/password/reset/confirm") - public ResponseEntity confirmReset(@RequestParam String code, @RequestParam String newPassword ,@RequestBody @Valid PasswordResetSendRequest req){ - userService.resetPasswordByCode(code, newPassword,req); + public ResponseEntity confirmReset(@RequestBody @Valid PasswordResetConfirmRequest req){ + userService.resetPasswordByCode(req); return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다.")); } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetConfirmRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetConfirmRequest.java index a97f847b..8b6780fb 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetConfirmRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetConfirmRequest.java @@ -27,6 +27,11 @@ public record PasswordResetConfirmRequest( String studentId, @NotBlank(message = "새 비밀번호는 필수입니다.") + @Schema( + description = "변경할 비밀번호 (변경 시에만 포함)", + example = "Newpassword123!" + ) + @Size(min = 8, message = "비밀번호는 최소 8자 이상 입력해야 합니다.") String newPassword diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java index 17e0f633..2adc1718 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java @@ -16,6 +16,7 @@ import org.sejongisc.backend.point.entity.TransactionReason; import org.sejongisc.backend.point.service.AccountService; import org.sejongisc.backend.point.service.PointLedgerService; +import org.sejongisc.backend.user.dto.PasswordResetConfirmRequest; import org.sejongisc.backend.user.dto.PasswordResetSendRequest; import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.entity.User; @@ -113,11 +114,11 @@ public void passwordResetSendCode(PasswordResetSendRequest req) { } @Transactional - public void resetPasswordByCode(String resetCode, String newPassword, PasswordResetSendRequest req) { + public void resetPasswordByCode(PasswordResetConfirmRequest req) { String email = validateNotBlank(req.email(), "이메일").trim(); String studentId = validateNotBlank(req.studentId(), "학번").trim(); - String inputCode = validateNotBlank(resetCode, "인증코드").trim(); + String inputCode = validateNotBlank(req.code(), "인증코드").trim(); // 사용자 조회 (이메일+학번 같이 확인하는 게 안전) User user = userRepository.findByEmailAndStudentId(email, studentId) @@ -128,26 +129,20 @@ public void resetPasswordByCode(String resetCode, String newPassword, PasswordRe // Redis에서 인증코드 조회 String redisKey = emailProperties.getKeyPrefix().getReset() + email; - Long ttl = redisTemplate.getExpire(redisKey, java.util.concurrent.TimeUnit.SECONDS); String savedCode = redisTemplate.opsForValue().get(redisKey); - log.info("RESET CODE CHECK key={}, inputCode={}, savedCode={}, ttlSec={}", - redisKey, inputCode, savedCode, ttl); - if (savedCode == null) { throw new CustomException(ErrorCode.RESET_CODE_EXPIRED); } - - if (!savedCode.equals(inputCode)) { throw new CustomException(ErrorCode.INVALID_RESET_CODE); } // 새 비밀번호 검증 + 암호화 저장 - String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(newPassword); + String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(req.newPassword()); user.setPasswordHash(passwordEncoder.encode(trimmedPassword)); // 인증코드 1회성 처리 (삭제) From 228acf89c896304dfd3b7c2e27abe5cfc2b41cbb Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Sat, 21 Feb 2026 23:40:51 +0900 Subject: [PATCH 10/14] =?UTF-8?q?chore(=EC=88=98=EC=A0=95):=20conflict=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=ED=95=A0=EB=95=8C=20=EB=82=9C=20=ED=9C=B4?= =?UTF-8?q?=EB=A8=BC=EC=97=90=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sejongisc/backend/common/auth/service/EmailService.java | 1 + .../backend/common/config/security/SecurityConstants.java | 2 +- .../java/org/sejongisc/backend/user/service/UserService.java | 1 + .../org/sejongisc/backend/user/service/UserServiceTest.java | 3 +-- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java index 3b21f25f..1750db5e 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java @@ -34,6 +34,7 @@ public class EmailService { private final RedisService redisService; private final SpringTemplateEngine templateEngine; private final UserRepository userRepository; + private final RedisTemplate redisTemplate; private final EmailProperties emailProperties; // 메일 발신자 diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java index e1c77c08..45b2ab68 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java @@ -18,7 +18,7 @@ public class SecurityConstants { //"/oauth2/**", "/favicon.ico", "/api/user/password/reset/confirm", - "/api/user/password/reset/send" + "/api/user/password/reset/send", "/actuator", "/actuator/**", "/error" diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java index 9b8d516d..278cc482 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java @@ -28,6 +28,7 @@ import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.util.PasswordPolicyValidator; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java index 0fb8e06c..6f425148 100644 --- a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java @@ -299,8 +299,7 @@ void signup_dataIntegrityViolation_throws() { // verify(userRepository).save(any(User.class)); // verify(oauthAccountRepository).save(any(UserOauthAccount.class)); // } - - */ + From 3a0f5ba2cc8d2cf2c33213744511da39508b4e16 Mon Sep 17 00:00:00 2001 From: lulyulalla Date: Sun, 1 Mar 2026 23:06:07 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat(=EC=B6=9C=EC=84=9D):=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=EC=84=B8=EC=85=98=20=EC=A0=84=EC=B2=B4=EC=9D=B8?= =?UTF-8?q?=EC=9B=90=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #262 --- .../AttendanceSessionController.java | 27 +++++++++++++++++- .../repository/SessionUserRepository.java | 2 ++ .../service/AttendanceSessionService.java | 28 +++++++++++++++++++ .../sejongisc/backend/user/entity/User.java | 1 + 4 files changed, 57 insertions(+), 1 deletion(-) 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 296aba97..683d6f38 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 @@ -14,6 +14,7 @@ import org.sejongisc.backend.common.auth.dto.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; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -213,4 +214,28 @@ public ResponseEntity deleteSession(@PathVariable UUID sessionId, attendanceSessionService.deleteSession(sessionId, adminUserId); return ResponseEntity.ok().build(); } -} + + /** + * 정규 세션 용 전체 회원 넣는 API(회장용) + */ + + @Operation( + summary = "정규세션에 active 상태인 전체 회원 추가", + description = """ + ## 인증(JWT): **필요** + + ## 요청 파라미터 ( `sessionId` ) + + ## 회장이면서 세션의 장이어야만 가능 + """ + ) + @PostMapping("/{sessionId}/add-all-users") + @PreAuthorize("hasRole('PRESIDENT')") + public ResponseEntity addAllUsers( + @PathVariable UUID sessionId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID adminUserId = requireUserId(userDetails); + attendanceSessionService.addAllUsers(sessionId, adminUserId); + return ResponseEntity.ok().build(); +}} 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 index be5b3689..2b3d511d 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/repository/SessionUserRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/repository/SessionUserRepository.java @@ -1,5 +1,6 @@ package org.sejongisc.backend.attendance.repository; +import org.sejongisc.backend.attendance.entity.AttendanceSession; import org.sejongisc.backend.attendance.entity.SessionUser; import org.sejongisc.backend.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; @@ -16,6 +17,7 @@ public interface SessionUserRepository extends JpaRepository { Optional findByAttendanceSession_AttendanceSessionIdAndUser_UserId(UUID sessionId, UUID userId); + boolean existsByAttendanceSessionAndUser(AttendanceSession session, User user); boolean existsByAttendanceSession_AttendanceSessionIdAndUser_UserId(UUID sessionId, UUID userId); 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 e8ac7bd2..0302c5a7 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 @@ -12,6 +12,7 @@ import org.sejongisc.backend.attendance.repository.SessionUserRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.user.entity.UserStatus; import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.stereotype.Service; @@ -158,4 +159,31 @@ public void closeSession(UUID sessionId, UUID userId) { log.info("출석 세션 종료 완료: 세션ID={}", sessionId); } + + public void addAllUsers(UUID sessionId, UUID userId) { + // 권한 확인 + attendanceAuthorizationService.ensureAdmin(sessionId, userId); + log.info("세션에 모든 사용자 추가 시작: 세션ID={}", sessionId); + + AttendanceSession session = attendanceSessionRepository.findById(sessionId) + .orElseThrow(() -> new CustomException(ErrorCode.SESSION_NOT_FOUND)); + + // UserStatus.ACTIVE인 사용자만 추가하도록 수정 + List allUsers = userRepository.findAllByStatus(UserStatus.ACTIVE); + + for (User user : allUsers) { + boolean alreadyAdded = sessionUserRepository.existsByAttendanceSessionAndUser(session, user); + if (!alreadyAdded) { + SessionUser su = SessionUser.builder() + .attendanceSession(session) + .user(user) + .sessionRole(SessionRole.PARTICIPANT) + .build(); + sessionUserRepository.save(su); + log.info("사용자 {} 세션에 추가됨", user.getUserId()); + } + } + + log.info("세션에 모든 사용자 추가 완료: 세션ID={}", sessionId); + } } diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java index ff1ba46d..b26529ba 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java @@ -92,6 +92,7 @@ public boolean isManagerPosition() { this.positionName.contains("회장"); } + // 기본값 지정 @PrePersist public void prePersist() { From 0fd3a534c5c045e3301b95b113e5d75c96350e4b Mon Sep 17 00:00:00 2001 From: Wi Seungjae <132977754+discipline24@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:25:13 +0900 Subject: [PATCH 12/14] Remove empty line before prePersist method Removed an empty line before the prePersist method. --- .../src/main/java/org/sejongisc/backend/user/entity/User.java | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java index b26529ba..ff1ba46d 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java @@ -92,7 +92,6 @@ public boolean isManagerPosition() { this.positionName.contains("회장"); } - // 기본값 지정 @PrePersist public void prePersist() { From f9c1d3cdf3ce8728a3842ae41b13b0b30c82df7e Mon Sep 17 00:00:00 2001 From: Wi Seungjae <132977754+discipline24@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:26:06 +0900 Subject: [PATCH 13/14] Remove unused redisTemplate field Removed unused redisTemplate field from EmailService. --- .../org/sejongisc/backend/common/auth/service/EmailService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java index 91196384..07d156f0 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java @@ -33,7 +33,6 @@ public class EmailService { private final RedisService redisService; private final SpringTemplateEngine templateEngine; private final UserRepository userRepository; - private final RedisTemplate redisTemplate; private final EmailProperties emailProperties; // 메일 발신자 From 23ee214106bb96c99bc0db596560241279ecddf3 Mon Sep 17 00:00:00 2001 From: Wi Seungjae <132977754+discipline24@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:26:26 +0900 Subject: [PATCH 14/14] Remove password reset operation and documentation Removed the password reset operation documentation and its related method. --- .../user/controller/UserController.java | 32 +------------------ 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java index 339a689b..ca50e006 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java +++ b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java @@ -69,34 +69,4 @@ public ResponseEntity> getAttendanceLogs(@AuthenticationPrinci public ResponseEntity> getBoardLogs(@AuthenticationPrincipal CustomUserDetails customUserDetails) { return ResponseEntity.ok(userService.getBoardActivityLog(customUserDetails.getUserId())); } - - @Operation( - summary = "비밀번호 재설정 : 인증코드와 새 비밀번호를 입력받아, 비밀번호를 변경합니다.", - description = """ - - ## 인증(JWT): **불필요** - - - ## 요청 파라미터 - - **`code`**: 이메일로 받은 인증코드 - - **`newPassword`**: 새 비밀번호 - - ## 요청 바디 ( `PasswordResetSendRequest` ) - - **`email`**: 가입된 이메일 - - **`studentId`**: 가입된 학번 - - ## 동작 설명 - - 이메일 + 학번으로 사용자를 다시 확인합니다. - - Redis에 저장된 인증코드와 입력한 `code`를 비교합니다. - - 인증코드가 일치하면 새 비밀번호를 정책 검증 후 암호화하여 저장합니다. - - 사용한 인증코드는 Redis에서 삭제합니다. (1회성) - - ## 반환값 - - 성공 메시지 (`비밀번호가 변경되었습니다.`) - """) - @PostMapping("/password/reset/confirm") - public ResponseEntity confirmReset(@RequestBody @Valid PasswordResetConfirmRequest req){ - userService.resetPasswordByCode(req); - return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다.")); - } -} \ No newline at end of file +}