diff --git a/src/main/java/org/cotato/csquiz/api/attendance/controller/AttendanceAdminController.java b/src/main/java/org/cotato/csquiz/api/attendance/controller/AttendanceAdminController.java deleted file mode 100644 index f3286ae9..00000000 --- a/src/main/java/org/cotato/csquiz/api/attendance/controller/AttendanceAdminController.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.cotato.csquiz.api.attendance.controller; - -import io.swagger.v3.oas.annotations.Operation; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.cotato.csquiz.api.attendance.dto.UpdateAttendanceRequest; -import org.cotato.csquiz.domain.attendance.service.AttendanceAdminService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Slf4j -@RestController -@RequiredArgsConstructor -@RequestMapping("/v2/api/attendances/admin") -public class AttendanceAdminController { - - private final AttendanceAdminService attendanceAdminService; - - @Operation(summary = "출석 정보 변경 API") - @PatchMapping - public ResponseEntity updateAttendance(@RequestBody @Valid UpdateAttendanceRequest request) { - attendanceAdminService.updateAttendance(request); - return ResponseEntity.noContent().build(); - } -} diff --git a/src/main/java/org/cotato/csquiz/api/attendance/controller/AttendanceController.java b/src/main/java/org/cotato/csquiz/api/attendance/controller/AttendanceController.java index 71ae9e5a..cae6a823 100644 --- a/src/main/java/org/cotato/csquiz/api/attendance/controller/AttendanceController.java +++ b/src/main/java/org/cotato/csquiz/api/attendance/controller/AttendanceController.java @@ -46,7 +46,7 @@ public class AttendanceController { @Operation(summary = "출석 정보 변경 API") @PatchMapping public ResponseEntity updateAttendance(@RequestBody @Valid UpdateAttendanceRequest request) { - attendanceAdminService.updateAttendance(request); + attendanceAdminService.updateAttendanceByAttendanceId(request); return ResponseEntity.noContent().build(); } @@ -117,7 +117,7 @@ public ResponseEntity submitOnlineAttendanceRecord(@RequestBody @Operation(summary = "부원의 기수별 출결 기록 반환 API") @GetMapping("/records/members") - public ResponseEntity findAllRecordsByGeneration(@RequestParam("generation-id") Long generationId , + public ResponseEntity findAllRecordsByGeneration(@RequestParam("generationId") Long generationId , @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok().body(attendanceRecordService.findAllRecordsBy(generationId, memberId)); } diff --git a/src/main/java/org/cotato/csquiz/api/attendance/dto/AttendanceDeadLineDto.java b/src/main/java/org/cotato/csquiz/api/attendance/dto/AttendanceDeadLineDto.java index 04940473..2753beb7 100644 --- a/src/main/java/org/cotato/csquiz/api/attendance/dto/AttendanceDeadLineDto.java +++ b/src/main/java/org/cotato/csquiz/api/attendance/dto/AttendanceDeadLineDto.java @@ -1,14 +1,17 @@ package org.cotato.csquiz.api.attendance.dto; import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalTime; import java.util.Objects; import lombok.Builder; import org.cotato.csquiz.domain.attendance.enums.DeadLine; public record AttendanceDeadLineDto( + @Schema(example = "19:05:00") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm:ss") LocalTime attendanceDeadLine, + @Schema(example = "19:20:00") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm:ss") LocalTime lateDeadLine ) { diff --git a/src/main/java/org/cotato/csquiz/api/attendance/dto/UpdateAttendanceRequest.java b/src/main/java/org/cotato/csquiz/api/attendance/dto/UpdateAttendanceRequest.java index 6c2c498f..95d615cf 100644 --- a/src/main/java/org/cotato/csquiz/api/attendance/dto/UpdateAttendanceRequest.java +++ b/src/main/java/org/cotato/csquiz/api/attendance/dto/UpdateAttendanceRequest.java @@ -20,4 +20,8 @@ public record UpdateAttendanceRequest( DEFAULT_LATE_DEADLINE.getTime()); } } + + public static UpdateAttendanceRequest of(Long attendanceId, Location location, AttendanceDeadLineDto attendTime) { + return new UpdateAttendanceRequest(attendanceId, location, attendTime); + } } diff --git a/src/main/java/org/cotato/csquiz/api/event/controller/EventController.java b/src/main/java/org/cotato/csquiz/api/event/controller/EventController.java new file mode 100644 index 00000000..4a7db551 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/api/event/controller/EventController.java @@ -0,0 +1,28 @@ +package org.cotato.csquiz.api.event.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.cotato.csquiz.common.sse.SseService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Tag(name = "서버에서 발생할 이벤트 구독 요청 API") +@RestController +@RequestMapping("/v2/api/events") +@RequiredArgsConstructor +public class EventController { + + private final SseService sseService; + + @Operation(summary = "최초 로그인 시 출결 알림 구독 API") + @GetMapping(value = "/attendances", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity subscribeAttendance(@AuthenticationPrincipal Long memberId) { + return ResponseEntity.ok().body(sseService.subscribeAttendance(memberId)); + } +} diff --git a/src/main/java/org/cotato/csquiz/api/event/dto/AttendanceStatusInfo.java b/src/main/java/org/cotato/csquiz/api/event/dto/AttendanceStatusInfo.java new file mode 100644 index 00000000..69ad4484 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/api/event/dto/AttendanceStatusInfo.java @@ -0,0 +1,14 @@ +package org.cotato.csquiz.api.event.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import org.cotato.csquiz.domain.attendance.enums.AttendanceOpenStatus; + +@Builder +public record AttendanceStatusInfo( + @Schema(description = "출결 PK", nullable = true) + Long attendanceId, + @Schema(description = "오픈 상태: 존재하면 OPEN, 없으면 CLOSED") + AttendanceOpenStatus openStatus +) { +} diff --git a/src/main/java/org/cotato/csquiz/api/session/controller/SessionController.java b/src/main/java/org/cotato/csquiz/api/session/controller/SessionController.java index 778ffa26..da0b4001 100644 --- a/src/main/java/org/cotato/csquiz/api/session/controller/SessionController.java +++ b/src/main/java/org/cotato/csquiz/api/session/controller/SessionController.java @@ -1,6 +1,7 @@ package org.cotato.csquiz.api.session.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -31,6 +32,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController +@Tag(name = "세션 정보", description = "세션 관련 API 입니다.") @RequestMapping("/v1/api/session") @RequiredArgsConstructor @Slf4j @@ -39,56 +41,60 @@ public class SessionController { private final SessionService sessionService; private final SessionImageService sessionImageService; - @Operation(summary = "Session 리스트 정보 얻기", description = "Get Session Infos") + @Operation(summary = "세션 목록 반환 API") @GetMapping("") public ResponseEntity> findSessionsByGenerationId(@RequestParam Long generationId) { return ResponseEntity.status(HttpStatus.OK).body(sessionService.findSessionsByGenerationId(generationId)); } - @Operation(summary = "Session 추가하기", description = "세션 추가하기") + + @Operation(summary = "CS ON인 세션 목록 반환 API") + @GetMapping("/cs-on") + public ResponseEntity> findAllCsOnSessionsByGenerationId( + @RequestParam Long generationId) { + return ResponseEntity.status(HttpStatus.OK) + .body(sessionService.findAllNotLinkedCsOnSessionsByGenerationId(generationId)); + } + + @Operation(summary = "Session 추가 API") @PostMapping(value = "/add", consumes = "multipart/form-data") public ResponseEntity addSession(@ModelAttribute @Valid AddSessionRequest request) throws ImageException { return ResponseEntity.status(HttpStatus.CREATED).body(sessionService.addSession(request)); } + @Operation(summary = "세션 수정 API") @PatchMapping(value = "/update") public ResponseEntity updateSession(@RequestBody @Valid UpdateSessionRequest request) { sessionService.updateSession(request); return ResponseEntity.noContent().build(); } + @Operation(summary = "세션 숫자 변경 API") @PatchMapping("/number") public ResponseEntity updateSessionNumber(@RequestBody @Valid UpdateSessionNumberRequest request) { sessionService.updateSessionNumber(request); return ResponseEntity.noContent().build(); } - @Operation(summary = "Session 수정 - 사진 순서", description = "세션 사진 순서 바꾸기") + @Operation(summary = "세션 사진 순서 변경 API") @PatchMapping("/image/order") public ResponseEntity updateSessionImageOrder(@RequestBody UpdateSessionImageOrderRequest request) { sessionImageService.updateSessionImageOrder(request); return ResponseEntity.noContent().build(); } - @Operation(summary = "Session 수정 - 사진 추가하기", description = "세션 수정 시 사진 추가하기, photoId 반환") + @Operation(summary = "세션 사진 추가 API") @PostMapping(value = "/image", consumes = "multipart/form-data") public ResponseEntity additionalSessionImage(@ModelAttribute @Valid AddSessionImageRequest request) throws ImageException { return ResponseEntity.status(HttpStatus.CREATED).body(sessionImageService.additionalSessionImage(request)); } - @Operation(summary = "Session 수정 - 사진 삭제하기", description = "사진 삭제하기") + @Operation(summary = "세션 사진 삭제 API") @DeleteMapping(value = "/image") public ResponseEntity deleteSessionImage(@RequestBody DeleteSessionImageRequest request) { sessionImageService.deleteSessionImage(request); return ResponseEntity.noContent().build(); } - - @GetMapping("/cs-on") - public ResponseEntity> findAllCsOnSessionsByGenerationId( - @RequestParam Long generationId) { - return ResponseEntity.status(HttpStatus.OK) - .body(sessionService.findAllNotLinkedCsOnSessionsByGenerationId(generationId)); - } } diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionRequest.java b/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionRequest.java index 054ac9dd..3b119d59 100644 --- a/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionRequest.java +++ b/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionRequest.java @@ -26,11 +26,11 @@ public record AddSessionRequest( @NotNull LocalDate sessionDate, - @Schema(example = "17:00:00") + @Schema(example = "19:05:00") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm:ss") LocalTime attendanceDeadLine, - @Schema(example = "17:00:00") + @Schema(example = "19:20:00") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm:ss") LocalTime lateDeadLine, diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionRequest.java b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionRequest.java index 995566ec..bde86c08 100644 --- a/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionRequest.java +++ b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionRequest.java @@ -1,5 +1,8 @@ package org.cotato.csquiz.api.session.dto; +import java.time.LocalDate; +import org.cotato.csquiz.api.attendance.dto.AttendanceDeadLineDto; +import org.cotato.csquiz.domain.attendance.embedded.Location; import org.cotato.csquiz.domain.generation.enums.CSEducation; import org.cotato.csquiz.domain.generation.enums.DevTalk; import org.cotato.csquiz.domain.generation.enums.ItIssue; @@ -12,6 +15,11 @@ public record UpdateSessionRequest( String title, String description, @NotNull + LocalDate sessionDate, + String placeName, + Location location, + AttendanceDeadLineDto attendTime, + @NotNull ItIssue itIssue, @NotNull Networking networking, diff --git a/src/main/java/org/cotato/csquiz/common/config/SchedulerConfig.java b/src/main/java/org/cotato/csquiz/common/config/SchedulerConfig.java new file mode 100644 index 00000000..f52d9774 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/config/SchedulerConfig.java @@ -0,0 +1,19 @@ +package org.cotato.csquiz.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class SchedulerConfig { + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(10); + scheduler.setThreadNamePrefix("scheduled-task-"); + scheduler.initialize(); + return scheduler; + } +} diff --git a/src/main/java/org/cotato/csquiz/common/config/SecurityConfig.java b/src/main/java/org/cotato/csquiz/common/config/SecurityConfig.java index 8a248f06..1e2793d9 100644 --- a/src/main/java/org/cotato/csquiz/common/config/SecurityConfig.java +++ b/src/main/java/org/cotato/csquiz/common/config/SecurityConfig.java @@ -1,11 +1,12 @@ package org.cotato.csquiz.common.config; +import lombok.RequiredArgsConstructor; import org.cotato.csquiz.common.config.filter.JwtAuthenticationFilter; import org.cotato.csquiz.common.config.filter.JwtAuthorizationFilter; import org.cotato.csquiz.common.config.filter.JwtExceptionFilter; import org.cotato.csquiz.common.config.jwt.JwtTokenProvider; import org.cotato.csquiz.common.config.jwt.RefreshTokenRepository; -import lombok.RequiredArgsConstructor; +import org.cotato.csquiz.common.error.handler.CustomAccessDeniedHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -38,6 +39,7 @@ public class SecurityConfig { private final RefreshTokenRepository refreshTokenRepository; private final CorsFilter corsFilter; private final JwtAuthorizationFilter jwtAuthorizationFilter; + private final CustomAccessDeniedHandler customAccessDeniedHandler; @Bean public AuthenticationManager authenticationManager(HttpSecurity httpSecurity) throws Exception { @@ -51,9 +53,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class); AuthenticationManager authenticationManager = sharedObject.build(); http.authenticationManager(authenticationManager); + http.cors(); + http.exceptionHandling(exception -> + exception.accessDeniedHandler(customAccessDeniedHandler)); http.csrf().disable() - .cors().disable() .formLogin().disable() .addFilter(new JwtAuthenticationFilter(authenticationManager, jwtTokenProvider, refreshTokenRepository)) .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class) @@ -83,6 +87,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/v2/api/attendance").hasAnyRole("ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/socket/token", "POST")) .hasAnyRole("MEMBER", "EDUCATION", "ADMIN") + .requestMatchers("/v2/api/events/attendances").hasAnyRole("MEMBER", "ADMIN", "EDUCATION") .requestMatchers("/v1/api/socket/**").hasAnyRole("EDUCATION", "ADMIN") .anyRequest().authenticated() ); diff --git a/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java b/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java index 50f28e69..11fc980e 100644 --- a/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java +++ b/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java @@ -92,6 +92,7 @@ public enum ErrorCode { ENUM_NOT_RESOLVED(HttpStatus.BAD_REQUEST, "S-005", "입력한 Enum이 존재하지 않습니다."), SCORER_LOCK_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S-006", "득점자 락 획득 과정에서 에러 발생"), IMAGE_CONVERT_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "S-007", "로컬 이미지 변환에 실패했습니다"), + SSE_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "S-008", "서버 이벤트 전송간 오류 발생"), ; private final HttpStatus httpStatus; diff --git a/src/main/java/org/cotato/csquiz/common/error/handler/CustomAccessDeniedHandler.java b/src/main/java/org/cotato/csquiz/common/error/handler/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..976e7cf4 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/error/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,21 @@ +package org.cotato.csquiz.common.error.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + log.error("Access Denied Exception 발생"); + response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage()); + } +} diff --git a/src/main/java/org/cotato/csquiz/common/error/handler/GlobalExceptionHandler.java b/src/main/java/org/cotato/csquiz/common/error/handler/GlobalExceptionHandler.java index 03e321fd..0afecac0 100644 --- a/src/main/java/org/cotato/csquiz/common/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/org/cotato/csquiz/common/error/handler/GlobalExceptionHandler.java @@ -1,17 +1,17 @@ package org.cotato.csquiz.common.error.handler; import com.amazonaws.services.s3.model.AmazonS3Exception; -import org.cotato.csquiz.common.error.exception.AppException; -import org.cotato.csquiz.common.error.exception.ImageException; -import org.cotato.csquiz.common.error.response.ErrorResponse; -import org.cotato.csquiz.common.error.response.MethodArgumentErrorResponse; -import org.cotato.csquiz.common.error.response.MethodArgumentErrorResponse.FieldErrorResponse; import jakarta.persistence.EntityNotFoundException; import jakarta.servlet.http.HttpServletRequest; import java.sql.SQLException; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.cotato.csquiz.common.error.ErrorCode; +import org.cotato.csquiz.common.error.exception.AppException; +import org.cotato.csquiz.common.error.exception.ImageException; +import org.cotato.csquiz.common.error.response.ErrorResponse; +import org.cotato.csquiz.common.error.response.MethodArgumentErrorResponse; +import org.cotato.csquiz.common.error.response.MethodArgumentErrorResponse.FieldErrorResponse; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; @@ -94,4 +94,3 @@ public ResponseEntity handleAmazonS3Exception(AmazonS3Exception e return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } } - diff --git a/src/main/java/org/cotato/csquiz/common/idempotency/CustomServletWrappingFilter.java b/src/main/java/org/cotato/csquiz/common/idempotency/CustomServletWrappingFilter.java index 6f285d8f..74b197a3 100644 --- a/src/main/java/org/cotato/csquiz/common/idempotency/CustomServletWrappingFilter.java +++ b/src/main/java/org/cotato/csquiz/common/idempotency/CustomServletWrappingFilter.java @@ -11,6 +11,9 @@ @Component public class CustomServletWrappingFilter extends OncePerRequestFilter { + + private static final String EVENT_PATH = "/v2/api/events"; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -20,4 +23,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse responseWrapper.copyBodyToResponse(); } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return request.getRequestURI().startsWith(EVENT_PATH); + } } diff --git a/src/main/java/org/cotato/csquiz/common/S3/S3Config.java b/src/main/java/org/cotato/csquiz/common/s3/S3Config.java similarity index 96% rename from src/main/java/org/cotato/csquiz/common/S3/S3Config.java rename to src/main/java/org/cotato/csquiz/common/s3/S3Config.java index 3aa0815d..e49cc838 100644 --- a/src/main/java/org/cotato/csquiz/common/S3/S3Config.java +++ b/src/main/java/org/cotato/csquiz/common/s3/S3Config.java @@ -1,4 +1,4 @@ -package org.cotato.csquiz.common.S3; +package org.cotato.csquiz.common.s3; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; diff --git a/src/main/java/org/cotato/csquiz/common/S3/S3Uploader.java b/src/main/java/org/cotato/csquiz/common/s3/S3Uploader.java similarity index 98% rename from src/main/java/org/cotato/csquiz/common/S3/S3Uploader.java rename to src/main/java/org/cotato/csquiz/common/s3/S3Uploader.java index ab63883a..117f94f7 100644 --- a/src/main/java/org/cotato/csquiz/common/S3/S3Uploader.java +++ b/src/main/java/org/cotato/csquiz/common/s3/S3Uploader.java @@ -1,4 +1,4 @@ -package org.cotato.csquiz.common.S3; +package org.cotato.csquiz.common.s3; import static org.cotato.csquiz.common.util.FileUtil.extractFileExtension; import static org.cotato.csquiz.common.util.FileUtil.isImageFileExtension; diff --git a/src/main/java/org/cotato/csquiz/common/SchedulerService.java b/src/main/java/org/cotato/csquiz/common/schedule/SchedulerService.java similarity index 65% rename from src/main/java/org/cotato/csquiz/common/SchedulerService.java rename to src/main/java/org/cotato/csquiz/common/schedule/SchedulerService.java index ddf97bf6..e16d978e 100644 --- a/src/main/java/org/cotato/csquiz/common/SchedulerService.java +++ b/src/main/java/org/cotato/csquiz/common/schedule/SchedulerService.java @@ -1,16 +1,21 @@ -package org.cotato.csquiz.common; +package org.cotato.csquiz.common.schedule; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Date; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.cotato.csquiz.common.sse.SseSender; +import org.cotato.csquiz.domain.attendance.enums.DeadLine; import org.cotato.csquiz.domain.auth.entity.RefusedMember; -import org.cotato.csquiz.domain.education.service.EducationService; -import org.cotato.csquiz.domain.education.service.QuizService; -import org.cotato.csquiz.domain.education.service.SocketService; import org.cotato.csquiz.domain.auth.enums.MemberRole; import org.cotato.csquiz.domain.auth.repository.MemberRepository; import org.cotato.csquiz.domain.auth.repository.RefusedMemberRepository; +import org.cotato.csquiz.domain.education.service.EducationService; +import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +29,8 @@ public class SchedulerService { private final RefusedMemberRepository refusedMemberRepository; private final MemberRepository memberRepository; private final EducationService educationService; + private final SseSender sseSender; + private final TaskScheduler taskScheduler; @Transactional @Scheduled(cron = "0 0 0 * * *") @@ -47,4 +54,13 @@ public void closeAllCsQuiz() { educationService.closeAllFlags(); log.info("[ CS 퀴즈 모두 닫기 Scheduler 완료 ]"); } + + // sessionDate 18시 50분에 출결을 구독 중인 부원들에게 출결 입력 시작 알림을 전송하는 스케줄러 + public void scheduleSessionNotification(LocalDate sessionDate) { + LocalDateTime notificationTime = LocalDateTime.of(sessionDate, DeadLine.ATTENDANCE_START_TIME.getTime()); + + ZonedDateTime zonedDateTime = notificationTime.atZone(ZoneId.of("Asia/Seoul")); + + taskScheduler.schedule(() -> sseSender.sendNotification(notificationTime), zonedDateTime.toInstant()); + } } diff --git a/src/main/java/org/cotato/csquiz/common/sse/SseAttendanceRepository.java b/src/main/java/org/cotato/csquiz/common/sse/SseAttendanceRepository.java new file mode 100644 index 00000000..ba25d205 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/sse/SseAttendanceRepository.java @@ -0,0 +1,33 @@ +package org.cotato.csquiz.common.sse; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Repository +@RequiredArgsConstructor +public class SseAttendanceRepository { + + private final Map attendances = new ConcurrentHashMap<>(); + + public Optional findById(final Long memberId) { + return Optional.ofNullable(attendances.get(memberId)); + } + + public void save(Long memberId, SseEmitter sseEmitter) { + attendances.put(memberId, sseEmitter); + } + + public void deleteById(Long memberId) { + attendances.remove(memberId); + } + + public List findAll() { + return attendances.values().stream() + .toList(); + } +} diff --git a/src/main/java/org/cotato/csquiz/common/sse/SseSender.java b/src/main/java/org/cotato/csquiz/common/sse/SseSender.java new file mode 100644 index 00000000..b98fe1a3 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/sse/SseSender.java @@ -0,0 +1,77 @@ +package org.cotato.csquiz.common.sse; + +import jakarta.persistence.EntityNotFoundException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.cotato.csquiz.api.event.dto.AttendanceStatusInfo; +import org.cotato.csquiz.common.error.ErrorCode; +import org.cotato.csquiz.common.error.exception.AppException; +import org.cotato.csquiz.domain.attendance.entity.Attendance; +import org.cotato.csquiz.domain.attendance.enums.AttendanceOpenStatus; +import org.cotato.csquiz.domain.attendance.repository.AttendanceRepository; +import org.cotato.csquiz.domain.attendance.util.AttendanceUtil; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter.DataWithMediaType; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Component +@RequiredArgsConstructor +public class SseSender { + + private static final String ATTENDANCE_STATUS = "AttendanceStatus"; + private final SseAttendanceRepository sseAttendanceRepository; + + private final AttendanceRepository attendanceRepository; + + public void sendInitialAttendanceStatus(SseEmitter sseEmitter) { + Optional maybeAttendance = attendanceRepository.findByAttendanceDeadLineDate(LocalDateTime.now()); + + if (maybeAttendance.isEmpty()) { + send(sseEmitter, SseEmitter.event() + .name(ATTENDANCE_STATUS) + .data(AttendanceStatusInfo.builder() + .openStatus(AttendanceOpenStatus.CLOSED) + .build()) + .build()); + return; + } + + send(sseEmitter, SseEmitter.event() + .name(ATTENDANCE_STATUS) + .data(AttendanceStatusInfo.builder() + .attendanceId(maybeAttendance.get().getId()) + .openStatus(AttendanceUtil.getAttendanceOpenStatus(maybeAttendance.get(), LocalDateTime.now())) + .build()) + .build()); + } + + // sessionDate 6시 50분에 출결을 구독 중인 부원들에게 출결 입력 시작 알림을 전송한다. + public void sendNotification(LocalDateTime notificationDate) { + Attendance attendance = attendanceRepository.findByAttendanceDeadLineDate(notificationDate) + .orElseThrow(() -> new EntityNotFoundException("해당 날짜에 진행하는 출석이 없습니다.")); + + Set data = SseEmitter.event() + .name(ATTENDANCE_STATUS) + .data(AttendanceStatusInfo.builder() + .attendanceId(attendance.getId()) + .openStatus(AttendanceOpenStatus.OPEN) + .build()) + .build(); + + List sseEmitters = sseAttendanceRepository.findAll(); + for (SseEmitter sseEmitter : sseEmitters) { + send(sseEmitter, data); + } + } + + private void send(SseEmitter sseEmitter, Set data) { + try { + sseEmitter.send(data); + } catch (Exception e) { + throw new AppException(ErrorCode.SSE_SEND_FAIL); + } + } +} diff --git a/src/main/java/org/cotato/csquiz/common/sse/SseService.java b/src/main/java/org/cotato/csquiz/common/sse/SseService.java new file mode 100644 index 00000000..daad3b45 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/sse/SseService.java @@ -0,0 +1,40 @@ +package org.cotato.csquiz.common.sse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SseService { + + private static final Long DEFAULT_TIMEOUT = 60 * 1000 * 60L; + + private final SseAttendanceRepository sseAttendanceRepository; + private final SseSender sseSender; + + public SseEmitter subscribeAttendance(final Long memberId) { + SseEmitter sseEmitter = new SseEmitter(DEFAULT_TIMEOUT); + + setBaseEmitterConfiguration(memberId, sseEmitter); + sseAttendanceRepository.save(memberId, sseEmitter); + + sseSender.sendInitialAttendanceStatus(sseEmitter); + + return sseEmitter; + } + + private void setBaseEmitterConfiguration(Long memberId, SseEmitter sseEmitter) { + sseEmitter.onCompletion(() -> { + log.info("---- [memberId]: {} on completion callback ----", memberId); + sseAttendanceRepository.deleteById(memberId); + }); + + sseEmitter.onTimeout(() -> { + log.info("---- [memberId]: {} on timeout callback ----", memberId); + sseEmitter.complete(); + }); + } +} diff --git a/src/main/java/org/cotato/csquiz/domain/attendance/entity/Attendance.java b/src/main/java/org/cotato/csquiz/domain/attendance/entity/Attendance.java index df8a0315..b04b7368 100644 --- a/src/main/java/org/cotato/csquiz/domain/attendance/entity/Attendance.java +++ b/src/main/java/org/cotato/csquiz/domain/attendance/entity/Attendance.java @@ -5,6 +5,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import java.time.LocalDate; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; @@ -36,7 +37,8 @@ public class Attendance extends BaseTimeEntity { private Long sessionId; @Builder - public Attendance(LocalDateTime attendanceDeadLine, LocalDateTime lateDeadLine, Location location, Session session) { + public Attendance(LocalDateTime attendanceDeadLine, LocalDateTime lateDeadLine, Location location, + Session session) { this.attendanceDeadLine = attendanceDeadLine; this.lateDeadLine = lateDeadLine; this.location = location; diff --git a/src/main/java/org/cotato/csquiz/domain/attendance/enums/DeadLine.java b/src/main/java/org/cotato/csquiz/domain/attendance/enums/DeadLine.java index 63fa5464..e1a5b28d 100644 --- a/src/main/java/org/cotato/csquiz/domain/attendance/enums/DeadLine.java +++ b/src/main/java/org/cotato/csquiz/domain/attendance/enums/DeadLine.java @@ -11,8 +11,8 @@ public enum DeadLine { ATTENDANCE_START_TIME(LocalTime.of(18, 50, 0), "고정 출석 시작 시간"), - DEFAULT_ATTENDANCE_DEADLINE(LocalTime.of(19, 5, 59), "기본 출석 마감 시간"), - DEFAULT_LATE_DEADLINE(LocalTime.of(19,20,59),"기본 지각 마감 시간"), + DEFAULT_ATTENDANCE_DEADLINE(LocalTime.of(19, 5, 0), "기본 출석 마감 시간"), + DEFAULT_LATE_DEADLINE(LocalTime.of(19,20,0),"기본 지각 마감 시간"), ATTENDANCE_END_TIME(LocalTime.of(20, 0,0), "고정 세션 종료 시간") ; diff --git a/src/main/java/org/cotato/csquiz/domain/attendance/repository/AttendanceRepository.java b/src/main/java/org/cotato/csquiz/domain/attendance/repository/AttendanceRepository.java index 63882a31..25c0b9b1 100644 --- a/src/main/java/org/cotato/csquiz/domain/attendance/repository/AttendanceRepository.java +++ b/src/main/java/org/cotato/csquiz/domain/attendance/repository/AttendanceRepository.java @@ -1,6 +1,8 @@ package org.cotato.csquiz.domain.attendance.repository; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.cotato.csquiz.domain.attendance.entity.Attendance; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -9,4 +11,9 @@ public interface AttendanceRepository extends JpaRepository { @Query("select a from Attendance a where a.sessionId in :sessionIds") List findAllBySessionIdsInQuery(@Param("sessionIds") List sessionIds); + + @Query("SELECT a FROM Attendance a WHERE DATE(a.attendanceDeadLine) = DATE(:time)") + Optional findByAttendanceDeadLineDate(@Param("time") LocalDateTime time); + + Optional findBySessionId(Long sessionId); } diff --git a/src/main/java/org/cotato/csquiz/domain/attendance/service/AttendanceAdminService.java b/src/main/java/org/cotato/csquiz/domain/attendance/service/AttendanceAdminService.java index a11e87fc..324fd891 100644 --- a/src/main/java/org/cotato/csquiz/domain/attendance/service/AttendanceAdminService.java +++ b/src/main/java/org/cotato/csquiz/domain/attendance/service/AttendanceAdminService.java @@ -33,7 +33,8 @@ public class AttendanceAdminService { @Transactional public void addAttendance(Session session, Location location, AttendanceDeadLineDto attendanceDeadLine) { - AttendanceUtil.validateAttendanceTime(attendanceDeadLine.attendanceDeadLine(), attendanceDeadLine.lateDeadLine()); + AttendanceUtil.validateAttendanceTime(attendanceDeadLine.attendanceDeadLine(), + attendanceDeadLine.lateDeadLine()); Attendance attendance = Attendance.builder() .session(session) @@ -47,26 +48,33 @@ public void addAttendance(Session session, Location location, AttendanceDeadLine attendanceRepository.save(attendance); } - @Transactional - public void updateAttendance(UpdateAttendanceRequest request) { + public void updateAttendanceByAttendanceId(UpdateAttendanceRequest request) { Attendance attendance = attendanceRepository.findById(request.attendanceId()) .orElseThrow(() -> new EntityNotFoundException("해당 출석 정보가 존재하지 않습니다")); Session attendanceSession = sessionRepository.findById(attendance.getSessionId()) .orElseThrow(() -> new EntityNotFoundException("출석과 연결된 세션을 찾을 수 없습니다")); - AttendanceUtil.validateAttendanceTime(request.attendTime().attendanceDeadLine(), request.attendTime().lateDeadLine()); + updateAttendance(attendanceSession, attendance, request + .attendTime(), request.location()); + } + + @Transactional + public void updateAttendance(Session attendanceSession, Attendance attendance, + AttendanceDeadLineDto attendanceDeadLine, Location location) { + AttendanceUtil.validateAttendanceTime(attendanceDeadLine.attendanceDeadLine(), + attendanceDeadLine.lateDeadLine()); if (attendanceSession.getSessionDate() == null) { throw new AppException(ErrorCode.SESSION_DATE_NOT_FOUND); } attendance.updateDeadLine( - LocalDateTime.of(attendanceSession.getSessionDate(), request.attendTime().attendanceDeadLine()) + LocalDateTime.of(attendanceSession.getSessionDate(), attendanceDeadLine.attendanceDeadLine()) .plusSeconds(DEFAULT_ATTEND_SECOND), - LocalDateTime.of(attendanceSession.getSessionDate(), request.attendTime() - .lateDeadLine()).plusSeconds(DEFAULT_ATTEND_SECOND)); - attendance.updateLocation(request.location()); + LocalDateTime.of(attendanceSession.getSessionDate(), attendanceDeadLine.lateDeadLine()) + .plusSeconds(DEFAULT_ATTEND_SECOND)); + attendance.updateLocation(location); attendanceRecordService.updateAttendanceStatus(attendance); } diff --git a/src/main/java/org/cotato/csquiz/domain/auth/service/MemberService.java b/src/main/java/org/cotato/csquiz/domain/auth/service/MemberService.java index 4da47ec1..92a94400 100644 --- a/src/main/java/org/cotato/csquiz/domain/auth/service/MemberService.java +++ b/src/main/java/org/cotato/csquiz/domain/auth/service/MemberService.java @@ -7,7 +7,7 @@ import org.cotato.csquiz.api.admin.dto.MemberInfoResponse; import org.cotato.csquiz.api.member.dto.MemberInfo; import org.cotato.csquiz.api.member.dto.MemberMyPageInfoResponse; -import org.cotato.csquiz.common.S3.S3Uploader; +import org.cotato.csquiz.common.s3.S3Uploader; import org.cotato.csquiz.api.member.dto.UpdatePhoneNumberRequest; import org.cotato.csquiz.common.config.jwt.JwtTokenProvider; import org.cotato.csquiz.common.entity.S3Info; diff --git a/src/main/java/org/cotato/csquiz/domain/education/service/QuizService.java b/src/main/java/org/cotato/csquiz/domain/education/service/QuizService.java index fbfc2a06..fa15a422 100644 --- a/src/main/java/org/cotato/csquiz/domain/education/service/QuizService.java +++ b/src/main/java/org/cotato/csquiz/domain/education/service/QuizService.java @@ -47,7 +47,7 @@ import org.cotato.csquiz.common.error.exception.AppException; import org.cotato.csquiz.common.error.ErrorCode; import org.cotato.csquiz.common.error.exception.ImageException; -import org.cotato.csquiz.common.S3.S3Uploader; +import org.cotato.csquiz.common.s3.S3Uploader; import org.cotato.csquiz.domain.auth.service.MemberService; import org.cotato.csquiz.domain.education.util.AnswerUtil; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/cotato/csquiz/domain/generation/entity/Session.java b/src/main/java/org/cotato/csquiz/domain/generation/entity/Session.java index a463f8a6..b6525dda 100644 --- a/src/main/java/org/cotato/csquiz/domain/generation/entity/Session.java +++ b/src/main/java/org/cotato/csquiz/domain/generation/entity/Session.java @@ -89,4 +89,12 @@ public void updateSessionContents(SessionContents sessionContents) { public void updateSessionTitle(String title) { this.title = title; } + + public void updateSessionDate(LocalDate sessionDate) { + this.sessionDate = sessionDate; + } + + public void updateSessionPlace(String placeName) { + this.placeName = placeName; + } } diff --git a/src/main/java/org/cotato/csquiz/domain/generation/service/SessionImageService.java b/src/main/java/org/cotato/csquiz/domain/generation/service/SessionImageService.java index 86890a50..2318caeb 100644 --- a/src/main/java/org/cotato/csquiz/domain/generation/service/SessionImageService.java +++ b/src/main/java/org/cotato/csquiz/domain/generation/service/SessionImageService.java @@ -16,7 +16,7 @@ import org.cotato.csquiz.api.session.dto.DeleteSessionImageRequest; import org.cotato.csquiz.api.session.dto.UpdateSessionImageOrderInfoRequest; import org.cotato.csquiz.api.session.dto.UpdateSessionImageOrderRequest; -import org.cotato.csquiz.common.S3.S3Uploader; +import org.cotato.csquiz.common.s3.S3Uploader; import org.cotato.csquiz.common.entity.S3Info; import org.cotato.csquiz.common.error.ErrorCode; import org.cotato.csquiz.common.error.exception.AppException; diff --git a/src/main/java/org/cotato/csquiz/domain/generation/service/SessionService.java b/src/main/java/org/cotato/csquiz/domain/generation/service/SessionService.java index dff5ebbb..18dacacd 100644 --- a/src/main/java/org/cotato/csquiz/domain/generation/service/SessionService.java +++ b/src/main/java/org/cotato/csquiz/domain/generation/service/SessionService.java @@ -1,6 +1,8 @@ package org.cotato.csquiz.domain.generation.service; import jakarta.persistence.EntityNotFoundException; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -14,8 +16,12 @@ import org.cotato.csquiz.api.session.dto.UpdateSessionNumberRequest; import org.cotato.csquiz.api.session.dto.UpdateSessionRequest; import org.cotato.csquiz.common.error.exception.ImageException; +import org.cotato.csquiz.common.schedule.SchedulerService; import org.cotato.csquiz.domain.attendance.embedded.Location; +import org.cotato.csquiz.domain.attendance.entity.Attendance; +import org.cotato.csquiz.domain.attendance.repository.AttendanceRepository; import org.cotato.csquiz.domain.attendance.service.AttendanceAdminService; +import org.cotato.csquiz.domain.attendance.service.AttendanceRecordService; import org.cotato.csquiz.domain.education.entity.Education; import org.cotato.csquiz.domain.education.service.EducationService; import org.cotato.csquiz.domain.generation.embedded.SessionContents; @@ -41,6 +47,9 @@ public class SessionService { private final AttendanceAdminService attendanceAdminService; private final EducationService educationService; private final SessionImageService sessionImageService; + private final SchedulerService schedulerService; + private final AttendanceRepository attendanceRepository; + private final AttendanceRecordService attendanceRecordService; @Transactional public AddSessionResponse addSession(AddSessionRequest request) throws ImageException { @@ -82,6 +91,7 @@ public AddSessionResponse addSession(AddSessionRequest request) throws ImageExce .build(); attendanceAdminService.addAttendance(session, location, attendanceDeadLine); + schedulerService.scheduleSessionNotification(savedSession.getSessionDate()); return AddSessionResponse.from(savedSession); } @@ -104,6 +114,8 @@ public void updateSession(UpdateSessionRequest request) { session.updateDescription(request.description()); session.updateSessionTitle(request.title()); + session.updateSessionPlace(request.placeName()); + session.updateSessionContents(SessionContents.builder() .csEducation(request.csEducation()) .devTalk(request.devTalk()) @@ -111,9 +123,29 @@ public void updateSession(UpdateSessionRequest request) { .networking(request.networking()) .build()); + updateSessionDate(session, request.sessionDate(), request.attendTime()); sessionRepository.save(session); } + public void updateSessionDate(Session session, LocalDate newDate, AttendanceDeadLineDto newDeadline) { + Attendance findAttendance = attendanceRepository.findBySessionId(session.getId()) + .orElseThrow(() -> new EntityNotFoundException("해당 세션의 출석이 존재하지 않습니다")); + + // 날짜가 바뀌지 않았고, 출결 시간이 모두 동일한 경우 + if (newDate.equals(session.getSessionDate()) && + findAttendance.getAttendanceDeadLine().toLocalTime().equals(newDeadline.attendanceDeadLine()) && + findAttendance.getLateDeadLine().toLocalTime().equals(newDeadline.lateDeadLine())) { + return; + } + session.updateSessionDate(newDate); + + LocalDateTime newAttendanceDeadline = LocalDateTime.of(newDate, newDeadline.attendanceDeadLine()); + LocalDateTime newLateDeadline = LocalDateTime.of(newDate, newDeadline.attendanceDeadLine()); + findAttendance.updateDeadLine(newAttendanceDeadline, newLateDeadline); + + attendanceRecordService.updateAttendanceStatus(findAttendance); + } + public List findSessionsByGenerationId(Long generationId) { Generation generation = generationRepository.findById(generationId) .orElseThrow(() -> new EntityNotFoundException("해당 기수를 찾을 수 없습니다.")); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 953cb6f8..fada2f74 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -22,3 +22,7 @@ spring: springdoc: swagger-ui: enabled: true + +logging: + level: + org.springframework.security: DEBUG diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a747069e..b609b164 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,6 +4,9 @@ spring: # 로컬, 개발, 운영 환경의 공통된 설정 + jpa: + open-in-view: false + data: redis: host: localhost