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 b8f3fb2b..06c21114 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 @@ -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; @@ -14,7 +16,13 @@ import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/attendance") @@ -30,21 +38,68 @@ public class AttendanceController { * POST /api/attendance/check-in * body: { "qrToken": "..." } */ - @Operation(summary = "체크인", description = "qrToken으로 출석 체크인합니다. (세션 멤버)") + @Operation( + summary = "체크인", + description = """ + ## 인증(JWT): **필요** + + ## 요청 바디 ( `AttendanceRoundQrTokenRequest` ) + - **`qrToken`**: QR 토큰 + + ## 동작 설명 + - qrToken이 유효한지 검증 + - 출석 라운드가 ACTIVE 상태인지 검증 + - 해당 멤버가 출석 세션의 멤버인지 검증 + - 해당 세션의 allowedMinutes 내에 출석체크하면 AttendanceStatus가 PRESENT + - allowedMinutes가 지난 후에 출석체크하면 LATE + + ## 응답 + - **`200 OK`** + + ## 에러코드 + - **`QR_TOKEN_MALFORMED`** : QR 토큰 형식이 올바르지 않습니다. + - **`ROUND_NOT_FOUND`** : 해당 출석 라운드가 존재하지 않습니다. + - **`ROUND_NOT_ACTIVE`** : 출석 라운드가 진행 중이 아닙니다. + - **`NOT_SESSION_MEMBER`** : 출석 세션의 멤버가 아닙니다. + - **`ALREADY_CHECKED_IN`** : 이미 출석 체크되었습니다. + + """) @PostMapping("/check-in") public ResponseEntity checkIn( @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody AttendanceRoundQrTokenRequest request ) { UUID userId = requireUserId(userDetails); - attendanceService.checkIn(userId, userDetails.getName(), request); + attendanceService.checkIn(userId, userDetails.getUsername(), request); return ResponseEntity.ok().build(); } /** * 라운드별 출석 명단 조회(관리자/OWNER) */ - @Operation(summary = "라운드 출석 명단 조회", description = "특정 라운드의 출석 기록을 조회합니다. (관리자/OWNER)") + @Operation( + summary = "라운드 출석 명단 조회", + description = """ + ## 인증(JWT) + - **필요** + + ## 권한 + - 세션 **MANAGER** 또는 **OWNER** + + ## 동작 설명 + - 특정 출석 라운드(`roundId`)에 기록된 모든 출석 데이터를 리스트로 반환 + + ## 응답 바디 ( `List` ) + - **유저 정보**: `userId`, `userName`(이름) + - **세션/라운드 정보**: 세션 제목, 라운드 이름, 장소, 시작 시간 등 + - **출석 상태**: `attendanceStatus` (PENDING, PRESENT, LATE, ABSENT, EXCUSED) + - **상세 기록**: `checkedAt`(체크인 시각), `note`(비고), `checkInLatitude/Longitude`(위치 정보) + + ## 에러 코드 + - **`ROUND_NOT_FOUND`**: 해당 출석 라운드가 존재하지 않습니다. + - **`NOT_SESSION_ADMIN`**: 세션 관리자 권한이 없습니다. + + """) @GetMapping("/rounds/{roundId}/records") public ResponseEntity> getAttendancesByRound( @PathVariable UUID roundId, @@ -55,38 +110,36 @@ public ResponseEntity> getAttendancesByRound( } /** - * 라운드 내 특정 유저 출석 상태 수정(관리자/OWNER) - * PUT /api/attendance/rounds/{roundId}/users/{userId} + * 라운드 내 특정 유저 출석 상태 수정(관리자/OWNER) PUT /api/attendance/rounds/{roundId}/users/{userId} */ @Operation( summary = "출석 상태 수정", description = """ - - ## 인증(JWT): **필요** - - - ## 권한 - - **세션 관리자 / OWNER** - - ## 경로 파라미터 - - **`roundId`**: 출석 상태를 수정할 라운드 ID (`UUID`) - - **`userId`**: 출석 상태를 수정할 대상 사용자 ID (`UUID`) - - ## 요청 바디 ( `AttendanceStatusUpdateRequest` ) - - **`status`**: 출석 상태 (필수) - - 허용값 예시: `PRESENT`, `LATE`, `ABSENT`, `EXCUSED` - - **`reason`**: 상태 수정 사유 (선택) - - 예: 지각 사유, 공결 사유 등 - - ## 동작 설명 - - 특정 라운드에서 특정 사용자의 출석 상태를 수정합니다. - - 요청한 사용자가 해당 세션의 관리자/OWNER인지 검증합니다. - - `status` 값과 `reason` 값을 기반으로 출석 상태를 반영합니다. - - ## 응답 - - **200 OK** - - 수정된 출석 정보 (`AttendanceResponse`) - """) + + ## 인증(JWT): **필요** + + ## 권한 + - 세션 **MANAGER** 또는 **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, @@ -95,22 +148,33 @@ public ResponseEntity updateAttendanceStatus( @Valid @RequestBody AttendanceStatusUpdateRequest request ) { UUID adminUserId = requireUserId(userDetails); - - // status가 enum이든 string이든 안전하게 문자열로 변환 - String statusStr = String.valueOf(request.getStatus()); - String reason = request.getReason(); - AttendanceResponse response = - attendanceService.updateAttendanceStatusByRound(adminUserId, roundId, userId, statusStr, reason); + attendanceService.updateAttendanceStatusByRound(adminUserId, roundId, userId, request); return ResponseEntity.ok(response); } /** - * (옵션) 내 출석 이력 조회 - * GET /api/attendance/me + * (옵션) 내 출석 이력 조회 GET /api/attendance/me */ - @Operation(summary = "내 출석 이력 조회", description = "로그인한 사용자의 출석 이력을 조회합니다.") + @Operation( + summary = "내 출석 이력 조회", + description = """ + ## 인증(JWT): **필요** + + ## 동작 설명 + - 현재 로그인한 사용자가 참여한 모든 세션 및 라운드의 출석 기록을 최신순으로 조회 + + ## 응답 바디 ( `List` ) + - **유저 정보**: `userId`, `userName`(이름) + - **세션/라운드 정보**: 세션 제목, 라운드 이름, 장소, 시작 시간 등 + - **출석 상태**: `attendanceStatus` (PENDING, PRESENT, LATE, ABSENT, EXCUSED) + - **상세 기록**: `checkedAt`(체크인 시각), `note`(비고), `checkInLatitude/Longitude`(위치 정보) + + ## 에러 코드 + - **`USER_NOT_FOUND`**: 유저 정보를 찾을 수 없습니다. + + """) @GetMapping("/me") public ResponseEntity> getMyAttendances( @AuthenticationPrincipal CustomUserDetails userDetails @@ -118,12 +182,4 @@ public ResponseEntity> getMyAttendances( UUID userId = requireUserId(userDetails); return ResponseEntity.ok(attendanceService.getAttendancesByUser(userId)); } - - - - // ------- 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 60d0e35f..3ea0e321 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 @@ -48,9 +48,8 @@ public class AttendanceRoundController { ## 인증(JWT): **필요** - ## 권한 - - **세션 관리자 / OWNER** + - **세션 OWNER** ## 경로 파라미터 - **`sessionId`**: 라운드를 생성할 출석 세션 ID (`UUID`) @@ -65,8 +64,20 @@ public class AttendanceRoundController { - **`locationName`**: 출석 위치명 (예: 공학관 301호) ## 동작 설명 - - 지정한 세션에 새로운 출석 라운드를 생성합니다. - - 요청한 사용자가 해당 세션의 관리자/OWNER인지 검증합니다. + - 특정 출석 세션 내에 새로운 출석 회차(Round) 생성 + - `closeAt`을 null 값으로 요청 시 기본값 : 시작 3시간 후로 설정 + - 요청한 사용자가 해당 세션의 OWNER인지 검증 + - AttendanceRoundStatus는 기본적으로 `UPCOMING`으로 생성 + - 시작 시간이 되면 `ACTIVE`로 변경 (최대 1분 정도 소요될 수 있음) + + ## 에러 코드 + - **`SESSION_NOT_FOUND`**: 해당 출석 세션이 존재하지 않습니다. + - **`NOT_SESSION_OWNER`**: 세션 소유자 권한이 없습니다. + - **`ROUND_DATE_REQUIRED`**: 출석 라운드 날짜가 필요합니다. + - **`START_AT_REQUIRED`**: 출석 라운드 시작 시간이 필요합니다. + - **`ROUND_NAME_REQUIRED`**: 출석 라운드 이름이 필요합니다. + - **`END_AT_MUST_BE_AFTER_START_AT`**: 종료 시간은 시작 시간 이후여야 합니다. + """) @PostMapping("/sessions/{sessionId}/rounds") public ResponseEntity createRound( @@ -80,18 +91,70 @@ public ResponseEntity createRound( return ResponseEntity.status(HttpStatus.CREATED).body(created); } + /** + * 라운드 삭제 (관리자/OWNER) DELETE /api/attendance/rounds/{roundId} + */ + @Operation( + summary = "라운드 삭제", + description = """ + ## 인증(JWT) + - **필요** + + ## 권한 + - **세션 MANAGER** 또는 **OWNER** + + ## 경로 파라미터 + - **`roundId`**: 삭제할 라운드 ID (`UUID`) + + ## 요청 바디 + - **없음** + + ## 동작 설명 + - 특정 출석 회차(Round)를 삭제 + - 요청 유저가 해당 세션의 관리 권한(MANAGER/OWNER)을 가졌는지 검증 + + ## 에러 코드 + - **`ROUND_NOT_FOUND`**: 해당 출석 라운드가 존재하지 않습니다. + - **`NOT_SESSION_ADMIN`**: 세션 관리자 권한이 없습니다. + + """) @DeleteMapping("/rounds/{roundId}") + public ResponseEntity deleteRound( + @PathVariable UUID roundId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UUID userId = requireUserId(userDetails); + attendanceRoundService.deleteRound(roundId, userId); + return ResponseEntity.noContent().build(); + } + /** * 라운드 조회 (세션 멤버) GET /api/attendance/rounds/{roundId} */ - @Operation(summary = "라운드 조회", description = "지정된 라운드 ID로 라운드 정보를 조회합니다. (세션 멤버)") + @Operation( + summary = "라운드 상세 조회", + description = """ + + ## 인증(JWT): **필요** + + ## 권한 + - **세션 MEMBER** + + ## 동작 설명 + - 특정 라운드 ID(`roundId`)를 통해 회차의 상세 정보 조회 + - 해당 세션에 참여하지 않은 멤버는 조회할 수 없음 + - `SessionUserController`에서 세션에 멤버 추가해야함 + + ## 에러 코드 + - **`ROUND_NOT_FOUND`**: 해당 출석 라운드가 존재하지 않습니다. + - **`NOT_SESSION_MEMBER`**: 출석 세션의 멤버가 아닙니다. + + """) @GetMapping("/rounds/{roundId}") public ResponseEntity getRound( @PathVariable UUID roundId, @AuthenticationPrincipal CustomUserDetails userDetails ) { UUID userId = requireUserId(userDetails); - - log.info("라운드 조회: roundId={}", roundId); AttendanceRoundResponse response = attendanceRoundService.getRound(roundId, userId); return ResponseEntity.ok(response); } @@ -99,15 +162,32 @@ public ResponseEntity getRound( /** * 세션 내 라운드 목록 조회 (세션 멤버) GET /api/attendance/sessions/{sessionId}/rounds */ - @Operation(summary = "세션의 라운드 목록 조회", description = "지정된 세션에 속한 모든 라운드 목록을 조회합니다. (세션 멤버)") - @GetMapping("/sessions/{sessionId}/rounds") + @Operation( + summary = "세션의 라운드 목록 조회", + description = """ + + ## 인증(JWT): **필요** + + ## 권한 + - **세션 MEMBER/MANAGER/OWNER** + + ## 동작 설명 + - 특정 출석 세션(`sessionId`)에 포함된 모든 라운드(회차) 목록 반환 + - 결과는 라운드 날짜(`roundDate`) 기준 **오름차순**으로 정렬되어 반환 + - 세션에 참여하지 않은 사용자는 목록을 조회할 수 없음 + + ## 에러 코드 + - **`SESSION_NOT_FOUND`**: 해당 출석 세션이 존재하지 않습니다. + - **`UNAUTHENTICATED`**: 인증되지 않은 사용자입니다. + - **`NOT_SESSION_MEMBER`**: 출석 세션의 멤버가 아닙니다. + + + """) @GetMapping("/sessions/{sessionId}/rounds") public ResponseEntity> getRoundsBySession( @PathVariable UUID sessionId, @AuthenticationPrincipal CustomUserDetails userDetails ) { UUID userId = requireUserId(userDetails); - - log.info("세션 내 라운드 목록 조회: sessionId={}", sessionId); List response = attendanceRoundService.getRoundsBySession(sessionId, userId); return ResponseEntity.ok(response); } @@ -115,14 +195,35 @@ public ResponseEntity> getRoundsBySession( /** * QR 토큰 발급 (관리자/OWNER) - 서버가 짧게 유효한 qrToken 발급 - 참가자에게는 토큰만 전달(사진 공유해도 만료되면 무효) */ - @Operation(summary = "QR 토큰 발급", description = "짧게 유효한 QR 토큰(qrToken)을 발급합니다. (관리자/OWNER, 라운드 ACTIVE 권장)") - @GetMapping("/rounds/{roundId}/qr-token") + @Operation( + summary = "QR 토큰 발급", + description = """ + ## 인증(JWT) + - **필요** + + ## 권한 + - **세션 MANAGER** 또는 **OWNER** + + ## 경로 파라미터 + - **`roundId`**: QR 토큰을 생성할 라운드 ID (`UUID`) + + ## 동작 설명 + - 특정 라운드 출석용 단기 유효 QR 토큰 생성 + - 토큰 유효 시간: 약 3분 + - 만료된 토큰이나 유효하지 않은 비밀키로 생성된 토큰은 출석 체크 불가 + - 해당 라운드의 상태가 **ACTIVE**인 경우에만 발급 가능 + + ## 에러 코드 + - **`ROUND_NOT_FOUND`**: 해당 출석 라운드가 존재하지 않습니다. + - **`ROUND_NOT_ACTIVE`**: 출석 라운드가 진행 중이 아닙니다. + - **`NOT_SESSION_ADMIN`**: 세션 관리자 권한이 없습니다. + + """) @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); } @@ -131,7 +232,35 @@ public ResponseEntity issueQrToken( * QR 토큰 SSE 스트림 (관리자/OWNER) - 폴링 없이 3분마다 PUSH * GET /api/attendance/rounds/{roundId}/qr-stream */ - @Operation(summary = "QR 토큰 SSE 스트림", description = "폴링 없이 SSE로 3분마다 갱신되는 QR 토큰을 push합니다. (관리자/OWNER, 라운드 ACTIVE)") + @Operation( + summary = "QR 토큰 SSE 스트림", + description = """ + ## 인증(JWT) + - **필요** + + ## 권한 + - **세션 MANAGER** 또는 **OWNER** + + ## 경로 파라미터 + - **`roundId`**: 실시간 QR 토큰을 구독할 라운드 ID (`UUID`) + + ## 요청 바디 + - **없음** (Content-Type: `text/event-stream`) + + ## 동작 설명 + - 폴링(Polling) 없이 서버에서 클라이언트로 갱신된 QR 토큰을 자동 Push + - 구독 즉시 현재 시점의 유효한 QR 토큰 최초 1회 발송 + - 이후 윈도우(약 3분) 경계마다 갱신된 토큰을 `qrToken` 이벤트명으로 발송 + - 연결 유지를 위해 15초마다 `ping` 이벤트 발송 (Idle Timeout 방지) + - 라운드 상태가 **ACTIVE**가 아니거나 종료될 경우 스트림 자동 종료 + - 브라우저 종료나 페이지 이탈 시 서버 리소스 자동 정리 + + ## 에러 코드 + - **`ROUND_NOT_FOUND`**: 해당 출석 라운드가 존재하지 않습니다. + - **`ROUND_NOT_ACTIVE`**: 출석 라운드가 진행 중이 아닙니다. + - **`NOT_SESSION_ADMIN`**: 세션 관리자 권한이 없습니다. + + """) @GetMapping(value = "/rounds/{roundId}/qr-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamQrToken( @PathVariable UUID roundId, @@ -140,20 +269,4 @@ public SseEmitter streamQrToken( UUID userId = requireUserId(userDetails); return qrTokenStreamService.subscribe(roundId, userId); } - - /** - * 라운드 삭제 (관리자/OWNER) DELETE /api/attendance/rounds/{roundId} - */ - @Operation(summary = "라운드 삭제", description = "지정된 라운드를 삭제합니다. (관리자/OWNER)") - @DeleteMapping("/rounds/{roundId}") - public ResponseEntity deleteRound( - @PathVariable UUID roundId, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - UUID userId = requireUserId(userDetails); - - log.info("라운드 삭제: roundId={}", roundId); - attendanceRoundService.deleteRound(roundId, userId); - return ResponseEntity.noContent().build(); - } } 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 493322c6..6a5b82b9 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 @@ -43,18 +43,23 @@ public class AttendanceSessionController { summary = "출석 세션 생성", description = """ - ## 인증(JWT): **필요** + ## 인증(JWT) + - **필요** + ## 요청 바디 ( `AttendanceSessionRequest` ) + - **`title`**: 세션 제목 (예: 2024 ISC 정기 세션) + - **`description`**: 세션 상세 설명 + - **`allowedMinutes`**: 지각 처리 전 체크인 허용 시간 (분 단위) - ## 요청 파라미터 ( `AttendanceSessionRequest` ) - - **`title`**: 세션 제목 - - **`description`**: 세션 설명 - - **`allowedMinutes`**: 체크인 허용 시간 (분) - - **`status`**: 세션 상태 (OPEN, CLOSED 등) + ## 동작 설명 + - 새로운 출석 세션(Session) 엔티티 생성 + - 세션 상태(`SessionStatus`)는 기본적으로 **OPEN**으로 설정 + - 세션 생성자를 해당 세션의 **OWNER** 권한으로 자동 등록 (`SessionUser`) - ## 반환값 없음 - """ - ) + ## 에러 코드 + - **`USER_NOT_FOUND`**: 유저를 찾을 수 없습니다. + + """) @PostMapping public ResponseEntity createSession(@AuthenticationPrincipal(expression = "userId") UUID userId, @RequestBody AttendanceSessionRequest request) { @@ -69,17 +74,18 @@ public ResponseEntity createSession(@AuthenticationPrincipal(expression = summary = "세션 상세 조회", description = """ ## 인증(JWT): **필요** + + ## 경로 파라미터 + - **`sessionId`**: 조회할 세션 ID (`UUID`) - ## 요청 파라미터 ( `sessionId` ) + ## 동작 설명 + - 특정 세션의 정보와 요청한 유저의 권한 정보를 함께 조회 + - 유저가 세션 멤버가 아닐 경우 역할(`myRole`)은 `null`로 반환 + + ## 에러 코드 + - **`SESSION_NOT_FOUND`**: 해당 출석 세션이 존재하지 않습니다. + - **`USER_NOT_FOUND`**: 유저를 찾을 수 없습니다. - ## 반환값 (`AttendanceSessionResponse`) - - **`sessionId`**: 세션 ID - - **`title`**: 세션 제목 - - **`description`**: 세션 설명 - - **`allowedMinutes`**: 체크인 허용 시간 (분) - - **`status`**: 세션 상태 (OPEN, CLOSED 등) - - **`myRole`**: 세션 소유자 | 관리자 | 참가자 - - **`permissions`**: 세션 권한 """ ) @GetMapping("/{sessionId}") @@ -109,8 +115,9 @@ public ResponseEntity getSession( - **`allowedMinutes`**: 체크인 허용 시간 (분) - **`status`**: 세션 상태 (OPEN, CLOSED 등) - ## 설명 - - 세션 관리자 권한 여부에 따라 반환되는 세션 정보가 다를 수 있음 + ## 동작 설명 + - 모든 출석 세션 목록을 반환 + - 특정 유저의 역할(Role) 정보 없이 세션의 정보만 리스트 형식으로 전달 """ ) @GetMapping @@ -119,7 +126,6 @@ public ResponseEntity> getAllSessions() { return ResponseEntity.ok(sessions); } - /** * 현재 활성 세션 목록 조회 - 체크인 가능한 세션들만 조회 */ @@ -130,14 +136,10 @@ public ResponseEntity> getAllSessions() { ## 요청 파라미터 : **없음** - ## 반환값 (`List`) - - **`sessionId`**: 세션 ID - - **`title`**: 세션 제목 - - **`description`**: 세션 설명 - - **`allowedMinutes`**: 체크인 허용 시간 (분) - - **`status`**: 세션 상태 (OPEN, CLOSED 등) - - **`myRole`**: 세션 소유자 | 관리자 | 참가자 - - **`permissions`**: 세션 권한 + ## 동작 설명 + - 현재 체크인이 가능한 상태(`SessionStatus.OPEN`)인 세션들만 필터링하여 반환 + - 종료된 세션(`CLOSED`)은 목록에서 제외됨 + """ ) @GetMapping("/active") @@ -147,7 +149,7 @@ public ResponseEntity> getActiveSessions() { } /** - * 세션 정보 수정 (관리자용) - 제목, 시간, 위치, 반경 등 수정 가능 - 코드는 변경 불가 + * 세션 정보 수정 (관리자용) - 제목, 설명, 허용 시간 수정 가능 */ @Operation( summary = "세션 정보 수정", @@ -159,7 +161,11 @@ public ResponseEntity> getActiveSessions() { - **`description`**: 세션 설명 - **`allowedMinutes`**: 체크인 허용 시간 (분) - ## 반환값 없음 + ## 반환값 : 없음 + + ## 에러 코드 + - **`SESSION_NOT_FOUND`**: 해당 출석 세션이 존재하지 않습니다. + """ ) @PutMapping("/{sessionId}") @@ -178,16 +184,29 @@ public ResponseEntity updateSession( @Operation( summary = "세션 종료", description = """ - ## 인증(JWT): **필요** - - ## 요청 파라미터 ( `sessionId` ) - - ## 반환값 없음 - """ - ) + ## 인증(JWT) + - **필요** + + ## 권한 + - **세션 MANAGER** 또는 **OWNER** + + ## 경로 파라미터 + - **`sessionId`**: 종료할 세션 ID (`UUID`) + + ## 동작 설명 + - 세션의 상태를 **CLOSED**로 변경하여 수동으로 종료 처리 + - 종료된 세션은 더 이상 체크인이 불가능하도록 제한됨 + + ## 에러 코드 + - **`SESSION_NOT_FOUND`**: 해당 출석 세션이 존재하지 않습니다. + - **`NOT_SESSION_ADMIN`**: 세션 관리자 권한이 없습니다. + + """) @PostMapping("/{sessionId}/close") - public ResponseEntity closeSession(@PathVariable UUID sessionId, - @AuthenticationPrincipal CustomUserDetails userDetails) { + public ResponseEntity closeSession( + @PathVariable UUID sessionId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { UUID adminUserId = requireUserId(userDetails); attendanceSessionService.closeSession(sessionId, adminUserId); return ResponseEntity.ok().build(); @@ -199,16 +218,29 @@ public ResponseEntity closeSession(@PathVariable UUID sessionId, @Operation( summary = "세션 삭제", description = """ - ## 인증(JWT): **필요** - - ## 요청 파라미터 ( `sessionId` ) - - ## 반환값 없음 - """ - ) + ## 인증(JWT) + - **필요** + + ## 권한 + - **세션 MANAGER** 또는 **OWNER** + + ## 경로 파라미터 + - **`sessionId`**: 삭제할 세션 ID (`UUID`) + + ## 동작 설명 + - 세션 정보를 삭제 + - **주의**: 해당 세션에 귀속된 모든 라운드 및 출석 기록이 삭제되며 복구가 불가능함 + + ## 에러 코드 + - **`SESSION_NOT_FOUND`**: 해당 출석 세션이 존재하지 않습니다. + - **`NOT_SESSION_ADMIN`**: 세션 관리자 권한이 없습니다. + + """) @DeleteMapping("/{sessionId}") - public ResponseEntity deleteSession(@PathVariable UUID sessionId, - @AuthenticationPrincipal CustomUserDetails userDetails) { + public ResponseEntity deleteSession( + @PathVariable UUID sessionId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { UUID adminUserId = requireUserId(userDetails); attendanceSessionService.deleteSession(sessionId, adminUserId); return ResponseEntity.ok().build(); 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 25846796..e7f9b6bf 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 @@ -40,16 +40,34 @@ public class SessionUserController { @Operation( summary = "세션에 사용자 추가", description = """ - ## 인증(JWT): **필요** - - """ - ) + ## 인증(JWT) + - **필요** + + ## 권한 + - **세션 OWNER** + + ## 경로 파라미터 + - **`sessionId`**: 사용자를 추가할 세션 ID (`UUID`) + + ## 쿼리 파라미터 + - **`userId`**: 추가할 대상 사용자의 ID (`UUID`) + + ## 동작 설명 + - 특정 사용자를 세션의 참여자(`PARTICIPANT`)로 추가 + - 세션 중간 참여 시, 오늘 이전의 모든 라운드에 대해 자동으로 **결석** 처리 및 사유("세션 중간 참여 - 이전 라운드는 자동 결석 처리") 등록 + + ## 에러 코드 + - **`SESSION_NOT_FOUND`**: 해당 출석 세션이 존재하지 않습니다. + - **`USER_NOT_FOUND`**: 유저를 찾을 수 없습니다. + - **`ALREADY_JOINED`**: 이미 출석 세션에 참여 중입니다. + - **`NOT_SESSION_OWNER`**: 세션 소유자 권한이 없습니다. + + """) @PostMapping("/{sessionId}/users") public ResponseEntity addUserToSession( @PathVariable UUID sessionId, @RequestParam UUID userId, @AuthenticationPrincipal CustomUserDetails userDetails) { - UUID adminUserId = requireUserId(userDetails); SessionUserResponse response = sessionUserService.addUserToSession(sessionId, userId, adminUserId); return ResponseEntity.status(HttpStatus.CREATED).body(response); @@ -61,10 +79,25 @@ public ResponseEntity addUserToSession( @Operation( summary = "세션에서 사용자 제거", description = """ - ## 인증(JWT): **필요** - - """ - ) + ## 인증(JWT) + - **필요** + + ## 권한 + - **세션 OWNER** + + ## 경로 파라미터 + - **`sessionId`**: 사용자를 제거할 세션 ID (`UUID`) + - **`userId`**: 제거할 대상 사용자의 ID (`UUID`) + + ## 동작 설명 + - 세션에서 특정 사용자를 제거 + - 해당 사용자가 이 세션에서 가졌던 모든 출석 기록(`Attendance`)을 함께 삭제 + + ## 에러 코드 + - **`SESSION_NOT_FOUND`**: 해당 출석 세션이 존재하지 않습니다. + - **`NOT_SESSION_OWNER`**: 세션 소유자 권한이 없습니다. + + """) @DeleteMapping("/{sessionId}/users/{userId}") public ResponseEntity removeUserFromSession( @PathVariable UUID sessionId, @@ -81,17 +114,29 @@ public ResponseEntity removeUserFromSession( @Operation( summary = "세션 참여자 조회", description = """ - ## 인증(JWT): **필요** - - """ - ) + ## 인증(JWT) + - **필요** + + ## 권한 + - **세션 MEMBER** (OWNER, MANAGER, PARTICIPANT 모두 가능) + + ## 경로 파라미터 + - **`sessionId`**: 참여자 목록을 조회할 세션 ID (`UUID`) + + ## 동작 설명 + - 세션에 참여 중인 모든 사용자 목록을 조회 + - 해당 세션의 멤버가 아닌 경우 조회 불가 + + ## 에러 코드 + - **`NOT_SESSION_MEMBER`**: 출석 세션의 멤버가 아닙니다. + + """) @GetMapping("/{sessionId}/users") - public ResponseEntity> getSessionUsers(@PathVariable UUID sessionId, + public ResponseEntity> getSessionUsers( + @PathVariable UUID sessionId, @AuthenticationPrincipal CustomUserDetails userDetails) { UUID adminUserId = requireUserId(userDetails); List users = sessionUserService.getSessionUsers(sessionId, adminUserId); return ResponseEntity.ok(users); } - - } 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 index 7a28f49a..916db698 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceAuthorizationService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceAuthorizationService.java @@ -4,8 +4,9 @@ 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.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; import org.springframework.stereotype.Service; /** @@ -17,11 +18,13 @@ public class AttendanceAuthorizationService { private final SessionUserRepository sessionUserRepository; public void ensureAuthenticated(UUID userId) { - if (userId == null) throw new IllegalStateException("UNAUTHENTICATED"); + if (userId == null) { + throw new CustomException(ErrorCode.UNAUTHENTICATED); + } } public SessionRole getSessionRole(UUID sessionId, UUID userId) { - if (userId == null) return null; // 조회용: 비로그인은 null role + if (userId == null) return null; return sessionUserRepository .findByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, userId) .map(SessionUser::getSessionRole) @@ -33,25 +36,24 @@ public SessionRole requireRole(UUID sessionId, UUID userId) { return sessionUserRepository .findByAttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, userId) .map(SessionUser::getSessionRole) - .orElseThrow(() -> new IllegalStateException("NOT_SESSION_MEMBER")); + .orElseThrow(() -> new CustomException(ErrorCode.NOT_SESSION_MEMBER)); } public void ensureMember(UUID sessionId, UUID userId) { - requireRole(sessionId, 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"); + throw new CustomException(ErrorCode.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"); + throw new CustomException(ErrorCode.NOT_SESSION_OWNER); } } -} - +} \ No newline at end of file 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 e21ba28d..12e018c1 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 @@ -10,6 +10,7 @@ import org.sejongisc.backend.activity.event.ActivityEvent; import org.sejongisc.backend.attendance.dto.AttendanceResponse; import org.sejongisc.backend.attendance.dto.AttendanceRoundQrTokenRequest; +import org.sejongisc.backend.attendance.dto.AttendanceStatusUpdateRequest; import org.sejongisc.backend.attendance.entity.Attendance; import org.sejongisc.backend.attendance.entity.AttendanceRound; import org.sejongisc.backend.attendance.entity.AttendanceSession; @@ -110,9 +111,11 @@ public AttendanceResponse updateAttendanceStatusByRound( UUID adminUserId, UUID roundId, UUID targetUserId, - String status, - String reason + AttendanceStatusUpdateRequest request ) { + String status = (request.getStatus() == null) ? null : request.getStatus().toString(); + String reason = request.getReason(); + AttendanceRound round = attendanceRoundRepository.findRoundById(roundId) .orElseThrow(() -> new CustomException(ErrorCode.ROUND_NOT_FOUND)); 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..40639def 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 @@ -106,7 +106,6 @@ public List getActiveSessions() { * 세션 정보 수정(세션 관리자용) */ public void updateSession(UUID sessionId, AttendanceSessionRequest request, UUID userId) { - log.info("출석 세션 수정 시작: 세션ID={}", sessionId); // 권한 확인 attendanceAuthorizationService.ensureAdmin(sessionId, userId); @@ -120,7 +119,6 @@ public void updateSession(UUID sessionId, AttendanceSessionRequest request, UUID .build(); attendanceSessionRepository.save(session); - log.info("출석 세션 수정 완료: 세션ID={}", sessionId); } @@ -128,7 +126,6 @@ public void updateSession(UUID sessionId, AttendanceSessionRequest request, UUID * 세션 완전 삭제(관리자 용) - CASCADE 관련 출석 기록도 함께 삭제 - 주의: 복구 불가능 */ public void deleteSession(UUID sessionId, UUID userId) { - log.info("출석 세션 삭제 시작: 세션ID={}", sessionId); // 권한 확인 attendanceAuthorizationService.ensureAdmin(sessionId, userId); @@ -136,7 +133,6 @@ public void deleteSession(UUID sessionId, UUID userId) { .orElseThrow(() -> new CustomException(ErrorCode.SESSION_NOT_FOUND)); attendanceSessionRepository.delete(session); - log.info("출석 세션 삭제 완료: 세션ID={}", sessionId); } @@ -145,7 +141,6 @@ public void deleteSession(UUID sessionId, UUID userId) { */ public void closeSession(UUID sessionId, UUID userId) { attendanceAuthorizationService.ensureAdmin(sessionId, userId); - log.info("출석 세션 종료 시작: 세션ID={}", sessionId); AttendanceSession session = attendanceSessionRepository.findById(sessionId) .orElseThrow(() -> new CustomException(ErrorCode.SESSION_NOT_FOUND)); @@ -155,7 +150,6 @@ public void closeSession(UUID sessionId, UUID userId) { .build(); attendanceSessionRepository.save(session); - log.info("출석 세션 종료 완료: 세션ID={}", sessionId); } } 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 a3cc43c4..4a4f55ec 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 @@ -36,8 +36,6 @@ public class SessionUserService { * 세션에 사용자 추가 (OWNER 전용 추천) */ 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) @@ -62,6 +60,7 @@ public SessionUserResponse addUserToSession(UUID sessionId, UUID targetUserId, U SessionUser saved = sessionUserRepository.save(sessionUser); createAbsentForPastRounds(sessionId, user); + log.info("세션 사용자 추가: sessionId={}, targetUserId={}, actorUserId={}", sessionId, targetUserId, actorUserId); return SessionUserResponse.from(saved); } @@ -70,8 +69,6 @@ public SessionUserResponse addUserToSession(UUID sessionId, UUID targetUserId, U * 세션에서 사용자 제거 (OWNER 전용 추천) - SessionUser 삭제 - 해당 유저의 이 세션 관련 Attendance 삭제 */ public void removeUserFromSession(UUID sessionId, UUID targetUserId, UUID actorUserId) { - log.info("세션 사용자 제거: sessionId={}, targetUserId={}, actorUserId={}", sessionId, targetUserId, actorUserId); - authorizationService.ensureOwner(sessionId, actorUserId); AttendanceSession session = attendanceSessionRepository.findById(sessionId) @@ -83,6 +80,7 @@ public void removeUserFromSession(UUID sessionId, UUID targetUserId, UUID actorU // 해당 세션의 라운드들에서 targetUserId의 출석 레코드 삭제 attendanceRepository.deleteAllByAttendanceRound_AttendanceSession_AttendanceSessionIdAndUser_UserId(sessionId, targetUserId); + log.info("세션 사용자 제거: sessionId={}, targetUserId={}, actorUserId={}", sessionId, targetUserId, actorUserId); } /** 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 index 8f4d2cb6..b0995e4a 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java @@ -1,14 +1,16 @@ package org.sejongisc.backend.attendance.util; import java.util.UUID; - import org.sejongisc.backend.common.auth.dto.CustomUserDetails; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; public class AuthUserUtil { private AuthUserUtil() {} public static UUID requireUserId(CustomUserDetails userDetails) { - if (userDetails == null) throw new IllegalStateException("UNAUTHENTICATED"); + if (userDetails == null) + throw new CustomException(ErrorCode.UNAUTHENTICATED); return userDetails.getUserId(); } 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 6e4ca4a2..31ede69a 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 @@ -169,7 +169,14 @@ public enum ErrorCode { TARGET_NOT_SESSION_MEMBER(HttpStatus.BAD_REQUEST, "대상 사용자가 출석 세션의 멤버가 아닙니다."), - CANNOT_DEMOTE_OWNER(HttpStatus.BAD_REQUEST, "출석 세션 소유자는 강등할 수 없습니다."); + CANNOT_DEMOTE_OWNER(HttpStatus.BAD_REQUEST, "출석 세션 소유자는 강등할 수 없습니다."), + + UNAUTHENTICATED(HttpStatus.UNAUTHORIZED, "인증되지 않은 사용자입니다."), + + NOT_SESSION_ADMIN(HttpStatus.FORBIDDEN, "세션 관리자 권한이 없습니다."), + + NOT_SESSION_OWNER(HttpStatus.FORBIDDEN, "세션 소유자 권한이 없습니다."); + private final HttpStatus status; private final String message;