From e5c1d671517e8650ac7e3a69eb08c27eac317b46 Mon Sep 17 00:00:00 2001 From: Jiwoo Kim Date: Sun, 4 Aug 2024 21:24:27 +0900 Subject: [PATCH] [Feat] Alert api #202 (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(Alert): Alert 도메인 구현 * feat(StringToDateTimeConverter): StringToDateTimeConverter 구현 * feat(Create_alert.sql): Alert 생성 SQL문 작성 * feat(TimeConfig): 시간을 서울로 지정하여 Clock Bean 등록 * feat(AlertService): AlertService 구현 * feat(AlertService): AlertService 에 필요한 port와 repository 구현 * refactor(MessageEventPort): 정해진 시간에 Message 를 보내니 MessageEventPort 가 더 적합한 이름이라 판단 * fix(StringToDateTimeConverter): 밀리초가 있는 경우 정상 파싱되지 않던 케이스 수정 * test: 예약 알림 인수테스트 작석 생성, 조회, 삭제 테스트 작성 * feat: Admin을 통하여 Alert를 등록하고, 삭제하고, 조회하도록 구현 * fix(StaffUpdater, UserUpdater): 정상동작하지 않는 updater deprecated 표기 * fix: LocalDateTime에서 Clock을 사용하도록 수정 * fix: MICROS 정밀도 무시 * fix(StringToDateTimeConverter): Milli-second 부분은 6자리 까지만 지원 --- .../adapter/in/web/AdminCommandApiV2.java | 45 ++++++-- .../admin/adapter/in/web/AdminQueryApiV2.java | 19 ++- .../in/web/dto/AdminAlertResponse.java | 20 ++++ .../event/AdminAdminAlertEventAdapter.java | 23 ++++ .../port/in/AdminCommandUseCase.java | 5 + .../port/in/AdminQueryUseCase.java | 3 + .../port/out/AdminAlertEventPort.java | 9 ++ .../port/out/AdminAlertQueryPort.java | 9 ++ .../service/AdminCommandService.java | 13 +++ .../service/AdminQueryService.java | 19 +++ .../in/event/AlertCommandEventListener.java | 34 ++++++ .../in/event/dto/AlertCreateEvent.java | 10 ++ .../in/event/dto/AlertDeleteEvent.java | 6 + .../adapter/in/web/AlertCommandApiV2.java | 47 ++++++++ .../in/web/dto/AlertCreateRequest.java | 12 ++ .../event/MessageFirebaseMessageAdapter.java | 15 +++ .../persistence/AlertPersistenceAdapter.java | 41 +++++++ .../out/persistence/AlertRepository.java | 22 ++++ .../port/in/AlertCommandUseCase.java | 14 +++ .../port/in/dto/AlertCreateCommand.java | 10 ++ .../port/out/AlertCommandPort.java | 7 ++ .../application/port/out/AlertQueryPort.java | 13 +++ .../port/out/MessageEventPort.java | 6 + .../application/port/out/dto/AlertDto.java | 29 +++++ .../application/service/AlertService.java | 108 ++++++++++++++++++ .../kustacks/kuring/alert/domain/Alert.java | 74 ++++++++++++ .../kuring/alert/domain/AlertStatus.java | 5 + .../common/dto/ResponseCodeAndMessages.java | 3 + .../converter/StringToDateTimeConverter.java | 48 ++++++++ .../kustacks/kuring/config/TimeConfig.java | 19 +++ .../in/event/MessageAdminEventListener.java | 8 ++ .../adapter/in/event/dto/AlertSendEvent.java | 12 ++ .../worker/update/staff/StaffUpdater.java | 5 +- .../worker/update/user/UserUpdater.java | 3 +- .../db/migration/V240803__Create_alert.sql | 13 +++ .../acceptance/AdminAcceptanceTest.java | 99 +++++++++++++++- .../kustacks/kuring/acceptance/AdminStep.java | 32 ++++++ .../application/service/AlertServiceTest.java | 80 +++++++++++++ .../kuring/alert/domain/AlertTest.java | 73 ++++++++++++ .../StringToDateTimeConverterTest.java | 50 ++++++++ 40 files changed, 1046 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/AdminAlertResponse.java create mode 100644 src/main/java/com/kustacks/kuring/admin/adapter/out/event/AdminAdminAlertEventAdapter.java create mode 100644 src/main/java/com/kustacks/kuring/admin/application/port/out/AdminAlertEventPort.java create mode 100644 src/main/java/com/kustacks/kuring/admin/application/port/out/AdminAlertQueryPort.java create mode 100644 src/main/java/com/kustacks/kuring/alert/adapter/in/event/AlertCommandEventListener.java create mode 100644 src/main/java/com/kustacks/kuring/alert/adapter/in/event/dto/AlertCreateEvent.java create mode 100644 src/main/java/com/kustacks/kuring/alert/adapter/in/event/dto/AlertDeleteEvent.java create mode 100644 src/main/java/com/kustacks/kuring/alert/adapter/in/web/AlertCommandApiV2.java create mode 100644 src/main/java/com/kustacks/kuring/alert/adapter/in/web/dto/AlertCreateRequest.java create mode 100644 src/main/java/com/kustacks/kuring/alert/adapter/out/event/MessageFirebaseMessageAdapter.java create mode 100644 src/main/java/com/kustacks/kuring/alert/adapter/out/persistence/AlertPersistenceAdapter.java create mode 100644 src/main/java/com/kustacks/kuring/alert/adapter/out/persistence/AlertRepository.java create mode 100644 src/main/java/com/kustacks/kuring/alert/application/port/in/AlertCommandUseCase.java create mode 100644 src/main/java/com/kustacks/kuring/alert/application/port/in/dto/AlertCreateCommand.java create mode 100644 src/main/java/com/kustacks/kuring/alert/application/port/out/AlertCommandPort.java create mode 100644 src/main/java/com/kustacks/kuring/alert/application/port/out/AlertQueryPort.java create mode 100644 src/main/java/com/kustacks/kuring/alert/application/port/out/MessageEventPort.java create mode 100644 src/main/java/com/kustacks/kuring/alert/application/port/out/dto/AlertDto.java create mode 100644 src/main/java/com/kustacks/kuring/alert/application/service/AlertService.java create mode 100644 src/main/java/com/kustacks/kuring/alert/domain/Alert.java create mode 100644 src/main/java/com/kustacks/kuring/alert/domain/AlertStatus.java create mode 100644 src/main/java/com/kustacks/kuring/common/utils/converter/StringToDateTimeConverter.java create mode 100644 src/main/java/com/kustacks/kuring/config/TimeConfig.java create mode 100644 src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AlertSendEvent.java create mode 100644 src/main/resources/db/migration/V240803__Create_alert.sql create mode 100644 src/test/java/com/kustacks/kuring/alert/application/service/AlertServiceTest.java create mode 100644 src/test/java/com/kustacks/kuring/alert/domain/AlertTest.java create mode 100644 src/test/java/com/kustacks/kuring/common/utils/converter/StringToDateTimeConverterTest.java diff --git a/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.java b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.java index 57b45c4e..db9280e6 100644 --- a/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.java +++ b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.java @@ -1,30 +1,32 @@ package com.kustacks.kuring.admin.adapter.in.web; -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.ADMIN_REAL_NOTICE_CREATE_SUCCESS; -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.ADMIN_TEST_NOTICE_CREATE_SUCCESS; - +import com.google.firebase.database.annotations.NotNull; import com.kustacks.kuring.admin.adapter.in.web.dto.RealNotificationRequest; import com.kustacks.kuring.admin.adapter.in.web.dto.TestNotificationRequest; import com.kustacks.kuring.admin.application.port.in.AdminCommandUseCase; import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand; import com.kustacks.kuring.admin.domain.AdminRole; +import com.kustacks.kuring.alert.adapter.in.web.dto.AlertCreateRequest; +import com.kustacks.kuring.alert.application.port.in.dto.AlertCreateCommand; import com.kustacks.kuring.auth.authorization.AuthenticationPrincipal; import com.kustacks.kuring.auth.context.Authentication; import com.kustacks.kuring.auth.secured.Secured; import com.kustacks.kuring.common.dto.BaseResponse; +import com.kustacks.kuring.common.utils.converter.StringToDateTimeConverter; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -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.bind.annotation.*; + +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.ADMIN_REAL_NOTICE_CREATE_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.ADMIN_TEST_NOTICE_CREATE_SUCCESS; @Tag(name = "Admin-Command", description = "관리자가 주체가 되는 정보 수정") @Validated @@ -59,6 +61,33 @@ public ResponseEntity> createRealNotice( return ResponseEntity.ok().body(new BaseResponse<>(ADMIN_REAL_NOTICE_CREATE_SUCCESS, null)); } + @Operation(summary = "예약 알림 등록", description = "서버에 예약 알림을 등록한다") + @SecurityRequirement(name = "JWT") + @Secured(AdminRole.ROLE_ROOT) + @PostMapping("/alerts") + public ResponseEntity> createAlert( + @RequestBody AlertCreateRequest request + ) { + AlertCreateCommand command = new AlertCreateCommand( + request.title(), request.content(), + StringToDateTimeConverter.convert(request.alertTime()) + ); + + adminCommandUseCase.addAlertSchedule(command); + return ResponseEntity.ok().body(new BaseResponse<>(ADMIN_REAL_NOTICE_CREATE_SUCCESS, null)); + } + + @Operation(summary = "예약 알림 삭제", description = "서버에 예약되어 있는 특정 알림을 삭제한다") + @SecurityRequirement(name = "JWT") + @Secured(AdminRole.ROLE_ROOT) + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/alerts/{id}") + public void cancelAlert( + @Parameter(description = "알림 아이디") @NotNull @PathVariable("id") Long id + ) { + adminCommandUseCase.cancelAlertSchedule(id); + } + @Hidden @Secured(AdminRole.ROLE_ROOT) @GetMapping("/subscribe/all") diff --git a/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminQueryApiV2.java b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminQueryApiV2.java index b5b72dab..947e8632 100644 --- a/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminQueryApiV2.java +++ b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminQueryApiV2.java @@ -1,5 +1,6 @@ package com.kustacks.kuring.admin.adapter.in.web; +import com.kustacks.kuring.admin.adapter.in.web.dto.AdminAlertResponse; import com.kustacks.kuring.admin.application.port.in.AdminQueryUseCase; import com.kustacks.kuring.admin.domain.AdminRole; import com.kustacks.kuring.auth.authorization.AuthenticationPrincipal; @@ -25,8 +26,7 @@ import java.util.List; -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.AUTH_AUTHENTICATION_SUCCESS; -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.FEEDBACK_SEARCH_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.*; @Tag(name = "Admin-Query", description = "관리자가 주체가 되는 정보 조회") @Validated @@ -43,11 +43,24 @@ public class AdminQueryApiV2 { @GetMapping("/feedbacks") public ResponseEntity>> getFeedbacks( @Parameter(description = "페이지") @RequestParam(name = "page") @Min(0) int page, - @Parameter(description = "단일 페이지의 사이즈, 1 ~ 30까지 허용") @RequestParam(name = "size") @Min(1) @Max(30) int size) { + @Parameter(description = "단일 페이지의 사이즈, 1 ~ 30까지 허용") @RequestParam(name = "size") @Min(1) @Max(30) int size + ) { List feedbacks = adminQueryUseCase.lookupFeedbacks(page, size); return ResponseEntity.ok().body(new BaseResponse<>(FEEDBACK_SEARCH_SUCCESS, feedbacks)); } + @Operation(summary = "예약 알림 조회", description = "어드민이 등록한 모든 예약 알림을 조회한다") + @SecurityRequirement(name = "JWT") + @Secured(AdminRole.ROLE_ROOT) + @GetMapping("/alerts") + public ResponseEntity>> getAlerts( + @Parameter(description = "페이지") @RequestParam(name = "page") @Min(0) int page, + @Parameter(description = "단일 페이지의 사이즈, 1 ~ 30까지 허용") @RequestParam(name = "size") @Min(1) @Max(30) int size + ) { + List alerts = adminQueryUseCase.lookupAlerts(page, size); + return ResponseEntity.ok().body(new BaseResponse<>(ALERT_SEARCH_SUCCESS, alerts)); + } + /** * Root 이상만 호출 가능한 테스트 API * diff --git a/src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/AdminAlertResponse.java b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/AdminAlertResponse.java new file mode 100644 index 00000000..bbebd533 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/AdminAlertResponse.java @@ -0,0 +1,20 @@ +package com.kustacks.kuring.admin.adapter.in.web.dto; + +import com.kustacks.kuring.alert.domain.AlertStatus; + +import java.time.LocalDateTime; + +public record AdminAlertResponse( + Long id, + String title, + String content, + AlertStatus status, + LocalDateTime wakeTime +) { + public static AdminAlertResponse of( + Long id, String title, String content, + AlertStatus status, LocalDateTime alertTime + ) { + return new AdminAlertResponse(id, title, content, status, alertTime); + } +}; diff --git a/src/main/java/com/kustacks/kuring/admin/adapter/out/event/AdminAdminAlertEventAdapter.java b/src/main/java/com/kustacks/kuring/admin/adapter/out/event/AdminAdminAlertEventAdapter.java new file mode 100644 index 00000000..dcf11021 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/adapter/out/event/AdminAdminAlertEventAdapter.java @@ -0,0 +1,23 @@ +package com.kustacks.kuring.admin.adapter.out.event; + +import com.kustacks.kuring.admin.application.port.out.AdminAlertEventPort; +import com.kustacks.kuring.alert.adapter.in.event.dto.AlertCreateEvent; +import com.kustacks.kuring.alert.adapter.in.event.dto.AlertDeleteEvent; +import com.kustacks.kuring.common.domain.Events; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +public class AdminAdminAlertEventAdapter implements AdminAlertEventPort { + + @Override + public void addAlertSchedule(String title, String content, LocalDateTime alertTime) { + Events.raise(new AlertCreateEvent(title, content, alertTime)); + } + + @Override + public void cancelAlertSchedule(Long id) { + Events.raise(new AlertDeleteEvent(id)); + } +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminCommandUseCase.java b/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminCommandUseCase.java index 1e78a5ad..18055dbc 100644 --- a/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminCommandUseCase.java +++ b/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminCommandUseCase.java @@ -2,9 +2,14 @@ import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand; import com.kustacks.kuring.admin.application.port.in.dto.TestNotificationCommand; +import com.kustacks.kuring.alert.application.port.in.dto.AlertCreateCommand; public interface AdminCommandUseCase { void createTestNotice(TestNotificationCommand command); void createRealNoticeForAllUser(RealNotificationCommand command); void subscribeAllUserSameTopic(); + + void addAlertSchedule(AlertCreateCommand command); + + void cancelAlertSchedule(Long id); } diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminQueryUseCase.java b/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminQueryUseCase.java index 9bd93965..2c3a4ae1 100644 --- a/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminQueryUseCase.java +++ b/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminQueryUseCase.java @@ -1,9 +1,12 @@ package com.kustacks.kuring.admin.application.port.in; +import com.kustacks.kuring.admin.adapter.in.web.dto.AdminAlertResponse; import com.kustacks.kuring.user.application.port.in.dto.AdminFeedbacksResult; import java.util.List; public interface AdminQueryUseCase { List lookupFeedbacks(int page, int size); + + List lookupAlerts(int page, int size); } diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminAlertEventPort.java b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminAlertEventPort.java new file mode 100644 index 00000000..cb9948a4 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminAlertEventPort.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.admin.application.port.out; + +import java.time.LocalDateTime; + +public interface AdminAlertEventPort { + void addAlertSchedule(String title, String content, LocalDateTime alertTime); + + void cancelAlertSchedule(Long id); +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminAlertQueryPort.java b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminAlertQueryPort.java new file mode 100644 index 00000000..94d6dba7 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminAlertQueryPort.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.admin.application.port.out; + +import com.kustacks.kuring.alert.domain.Alert; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface AdminAlertQueryPort { + Page findAllAlertByPageRequest(Pageable pageable); +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/service/AdminCommandService.java b/src/main/java/com/kustacks/kuring/admin/application/service/AdminCommandService.java index b7fb2b14..148e791b 100644 --- a/src/main/java/com/kustacks/kuring/admin/application/service/AdminCommandService.java +++ b/src/main/java/com/kustacks/kuring/admin/application/service/AdminCommandService.java @@ -4,9 +4,11 @@ import com.kustacks.kuring.admin.application.port.in.AdminCommandUseCase; import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand; import com.kustacks.kuring.admin.application.port.in.dto.TestNotificationCommand; +import com.kustacks.kuring.admin.application.port.out.AdminAlertEventPort; import com.kustacks.kuring.admin.application.port.out.AdminEventPort; import com.kustacks.kuring.admin.application.port.out.AdminUserFeedbackPort; import com.kustacks.kuring.admin.domain.Admin; +import com.kustacks.kuring.alert.application.port.in.dto.AlertCreateCommand; import com.kustacks.kuring.auth.userdetails.UserDetailsServicePort; import com.kustacks.kuring.common.annotation.UseCase; import com.kustacks.kuring.common.properties.ServerProperties; @@ -29,6 +31,7 @@ public class AdminCommandService implements AdminCommandUseCase { private final UserDetailsServicePort userDetailsServicePort; private final AdminUserFeedbackPort adminUserFeedbackPort; + private final AdminAlertEventPort adminAlertEventPort; private final AdminEventPort adminEventPort; private final NoticeProperties noticeProperties; private final ServerProperties serverProperties; @@ -66,6 +69,16 @@ public void createRealNoticeForAllUser(RealNotificationCommand command) { adminEventPort.sendNotificationByAdmin(command.title(), command.body(), command.url()); } + @Override + public void addAlertSchedule(AlertCreateCommand command) { + adminAlertEventPort.addAlertSchedule(command.title(), command.content(), command.alertTime()); + } + + @Override + public void cancelAlertSchedule(Long id) { + adminAlertEventPort.cancelAlertSchedule(id); + } + /** * TODO : 1회성 API - client v2 배포 후, 단 한번 모든 사용자를 공통 topic에 구독시킨 후 제거 예정 */ diff --git a/src/main/java/com/kustacks/kuring/admin/application/service/AdminQueryService.java b/src/main/java/com/kustacks/kuring/admin/application/service/AdminQueryService.java index 7d4b62f7..65f09822 100644 --- a/src/main/java/com/kustacks/kuring/admin/application/service/AdminQueryService.java +++ b/src/main/java/com/kustacks/kuring/admin/application/service/AdminQueryService.java @@ -1,12 +1,15 @@ package com.kustacks.kuring.admin.application.service; +import com.kustacks.kuring.admin.adapter.in.web.dto.AdminAlertResponse; import com.kustacks.kuring.admin.application.port.in.AdminQueryUseCase; +import com.kustacks.kuring.admin.application.port.out.AdminAlertQueryPort; import com.kustacks.kuring.admin.application.port.out.AdminUserFeedbackPort; import com.kustacks.kuring.common.annotation.UseCase; import com.kustacks.kuring.user.application.port.in.dto.AdminFeedbacksResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -17,6 +20,7 @@ public class AdminQueryService implements AdminQueryUseCase { private final AdminUserFeedbackPort adminUserFeedbackPort; + private final AdminAlertQueryPort adminAlertQueryPort; @Transactional(readOnly = true) @Override @@ -31,4 +35,19 @@ public List lookupFeedbacks(int page, int size) { )) .toList(); } + + @Override + public List lookupAlerts(int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt"))); + return adminAlertQueryPort.findAllAlertByPageRequest(pageRequest) + .stream() + .map(alert -> AdminAlertResponse.of( + alert.getId(), + alert.getTitle(), + alert.getContent(), + alert.getStatus(), + alert.getAlertTime() + )) + .toList(); + } } diff --git a/src/main/java/com/kustacks/kuring/alert/adapter/in/event/AlertCommandEventListener.java b/src/main/java/com/kustacks/kuring/alert/adapter/in/event/AlertCommandEventListener.java new file mode 100644 index 00000000..a0ad3127 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/adapter/in/event/AlertCommandEventListener.java @@ -0,0 +1,34 @@ +package com.kustacks.kuring.alert.adapter.in.event; + +import com.kustacks.kuring.alert.adapter.in.event.dto.AlertCreateEvent; +import com.kustacks.kuring.alert.adapter.in.event.dto.AlertDeleteEvent; +import com.kustacks.kuring.alert.application.port.in.AlertCommandUseCase; +import com.kustacks.kuring.alert.application.port.in.dto.AlertCreateCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AlertCommandEventListener { + + private final AlertCommandUseCase alertCommandUseCase; + + @EventListener + public void createAlert( + AlertCreateEvent event + ) { + AlertCreateCommand command = new AlertCreateCommand( + event.title(), event.content(), event.alertTime() + ); + + alertCommandUseCase.addAlertSchedule(command); + } + + @EventListener + public void cancelAlert( + AlertDeleteEvent event + ) { + alertCommandUseCase.cancelAlertSchedule(event.id()); + } +} diff --git a/src/main/java/com/kustacks/kuring/alert/adapter/in/event/dto/AlertCreateEvent.java b/src/main/java/com/kustacks/kuring/alert/adapter/in/event/dto/AlertCreateEvent.java new file mode 100644 index 00000000..35fa96e0 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/adapter/in/event/dto/AlertCreateEvent.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.alert.adapter.in.event.dto; + +import java.time.LocalDateTime; + +public record AlertCreateEvent( + String title, + String content, + LocalDateTime alertTime +) { +} diff --git a/src/main/java/com/kustacks/kuring/alert/adapter/in/event/dto/AlertDeleteEvent.java b/src/main/java/com/kustacks/kuring/alert/adapter/in/event/dto/AlertDeleteEvent.java new file mode 100644 index 00000000..235365c8 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/adapter/in/event/dto/AlertDeleteEvent.java @@ -0,0 +1,6 @@ +package com.kustacks.kuring.alert.adapter.in.event.dto; + +public record AlertDeleteEvent( + Long id +) { +} diff --git a/src/main/java/com/kustacks/kuring/alert/adapter/in/web/AlertCommandApiV2.java b/src/main/java/com/kustacks/kuring/alert/adapter/in/web/AlertCommandApiV2.java new file mode 100644 index 00000000..dbb9cc65 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/adapter/in/web/AlertCommandApiV2.java @@ -0,0 +1,47 @@ +package com.kustacks.kuring.alert.adapter.in.web; + +import com.google.firebase.database.annotations.NotNull; +import com.kustacks.kuring.alert.adapter.in.web.dto.AlertCreateRequest; +import com.kustacks.kuring.alert.application.port.in.AlertCommandUseCase; +import com.kustacks.kuring.alert.application.port.in.dto.AlertCreateCommand; +import com.kustacks.kuring.common.annotation.RestWebAdapter; +import com.kustacks.kuring.common.utils.converter.StringToDateTimeConverter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Alert-Command", description = "알림 에약") +@Validated +@RequiredArgsConstructor +@RestWebAdapter(path = "/api/v2/alerts") +public class AlertCommandApiV2 { + + private final AlertCommandUseCase alertCommandUseCase; + + @Operation(summary = "예약 알림 등록", description = "서버에 예약 알림을 등록한다") + @PostMapping + public void createAlert( + @RequestBody AlertCreateRequest request + ) { + AlertCreateCommand command = new AlertCreateCommand( + request.title(), request.content(), + StringToDateTimeConverter.convert(request.alertTime()) + ); + + alertCommandUseCase.addAlertSchedule(command); + } + + @Operation(summary = "예약 알림 삭제", description = "서버에 예약되어 있는 특정 알림을 삭제한다") + @DeleteMapping("/{id}") + public void cancelAlert( + @Parameter(description = "알림 아이디") @NotNull @PathVariable Long id + ) { + alertCommandUseCase.cancelAlertSchedule(id); + } +} diff --git a/src/main/java/com/kustacks/kuring/alert/adapter/in/web/dto/AlertCreateRequest.java b/src/main/java/com/kustacks/kuring/alert/adapter/in/web/dto/AlertCreateRequest.java new file mode 100644 index 00000000..ecfbf89a --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/adapter/in/web/dto/AlertCreateRequest.java @@ -0,0 +1,12 @@ +package com.kustacks.kuring.alert.adapter.in.web.dto; + +import jakarta.validation.constraints.NotEmpty; + +public record AlertCreateRequest( + @NotEmpty(message = "제목은 필수입니다") + String title, + String content, + @NotEmpty(message = "알림 시간은 필수입니다") + String alertTime +) { +} diff --git a/src/main/java/com/kustacks/kuring/alert/adapter/out/event/MessageFirebaseMessageAdapter.java b/src/main/java/com/kustacks/kuring/alert/adapter/out/event/MessageFirebaseMessageAdapter.java new file mode 100644 index 00000000..83f0a272 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/adapter/out/event/MessageFirebaseMessageAdapter.java @@ -0,0 +1,15 @@ +package com.kustacks.kuring.alert.adapter.out.event; + +import com.kustacks.kuring.alert.application.port.out.MessageEventPort; +import com.kustacks.kuring.common.domain.Events; +import com.kustacks.kuring.message.adapter.in.event.dto.AlertSendEvent; +import org.springframework.stereotype.Component; + +@Component +public class MessageFirebaseMessageAdapter implements MessageEventPort { + + @Override + public void sendMessageEvent(String title, String content) { + Events.raise(new AlertSendEvent(title, content)); + } +} diff --git a/src/main/java/com/kustacks/kuring/alert/adapter/out/persistence/AlertPersistenceAdapter.java b/src/main/java/com/kustacks/kuring/alert/adapter/out/persistence/AlertPersistenceAdapter.java new file mode 100644 index 00000000..ec276daf --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/adapter/out/persistence/AlertPersistenceAdapter.java @@ -0,0 +1,41 @@ +package com.kustacks.kuring.alert.adapter.out.persistence; + +import com.kustacks.kuring.admin.application.port.out.AdminAlertQueryPort; +import com.kustacks.kuring.alert.application.port.out.AlertCommandPort; +import com.kustacks.kuring.alert.application.port.out.AlertQueryPort; +import com.kustacks.kuring.alert.domain.Alert; +import com.kustacks.kuring.alert.domain.AlertStatus; +import com.kustacks.kuring.common.annotation.PersistenceAdapter; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +@PersistenceAdapter +@RequiredArgsConstructor +public class AlertPersistenceAdapter implements AlertCommandPort, AlertQueryPort, AdminAlertQueryPort { + + private final AlertRepository alertRepository; + + @Override + public Alert save(Alert alert) { + return alertRepository.save(alert); + } + + @Override + public List findAllByStatus(AlertStatus status) { + return alertRepository.findAllByStatus(status); + } + + @Override + public Optional findByIdAndStatusForUpdate(Long id, AlertStatus status) { + return alertRepository.findByIdAndStatusForUpdate(id, status); + } + + @Override + public Page findAllAlertByPageRequest(Pageable pageable) { + return alertRepository.findAll(pageable); + } +} diff --git a/src/main/java/com/kustacks/kuring/alert/adapter/out/persistence/AlertRepository.java b/src/main/java/com/kustacks/kuring/alert/adapter/out/persistence/AlertRepository.java new file mode 100644 index 00000000..3fed07bb --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/adapter/out/persistence/AlertRepository.java @@ -0,0 +1,22 @@ +package com.kustacks.kuring.alert.adapter.out.persistence; + +import com.kustacks.kuring.alert.domain.Alert; +import com.kustacks.kuring.alert.domain.AlertStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface AlertRepository extends JpaRepository { + + @Query("SELECT a FROM Alert a WHERE a.status = :status") + List findAllByStatus(@Param("status") AlertStatus status); + + @Query("SELECT a FROM Alert a WHERE a.id = :id and a.status = :status") + Optional findByIdAndStatusForUpdate( + @Param("id") Long id, + @Param("status") AlertStatus status + ); +} diff --git a/src/main/java/com/kustacks/kuring/alert/application/port/in/AlertCommandUseCase.java b/src/main/java/com/kustacks/kuring/alert/application/port/in/AlertCommandUseCase.java new file mode 100644 index 00000000..ce7c75f5 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/application/port/in/AlertCommandUseCase.java @@ -0,0 +1,14 @@ +package com.kustacks.kuring.alert.application.port.in; + +import com.kustacks.kuring.alert.application.port.in.dto.AlertCreateCommand; + +public interface AlertCommandUseCase { + + void initAlertSchedule(); + + void addAlertSchedule(AlertCreateCommand command); + + void cancelAlertSchedule(Long id); + + void sendAlert(Long id); +} diff --git a/src/main/java/com/kustacks/kuring/alert/application/port/in/dto/AlertCreateCommand.java b/src/main/java/com/kustacks/kuring/alert/application/port/in/dto/AlertCreateCommand.java new file mode 100644 index 00000000..6f7ce454 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/application/port/in/dto/AlertCreateCommand.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.alert.application.port.in.dto; + +import java.time.LocalDateTime; + +public record AlertCreateCommand( + String title, + String content, + LocalDateTime alertTime +) { +} diff --git a/src/main/java/com/kustacks/kuring/alert/application/port/out/AlertCommandPort.java b/src/main/java/com/kustacks/kuring/alert/application/port/out/AlertCommandPort.java new file mode 100644 index 00000000..da5e575c --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/application/port/out/AlertCommandPort.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.alert.application.port.out; + +import com.kustacks.kuring.alert.domain.Alert; + +public interface AlertCommandPort { + Alert save(Alert alert); +} diff --git a/src/main/java/com/kustacks/kuring/alert/application/port/out/AlertQueryPort.java b/src/main/java/com/kustacks/kuring/alert/application/port/out/AlertQueryPort.java new file mode 100644 index 00000000..c1660ac7 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/application/port/out/AlertQueryPort.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.alert.application.port.out; + +import com.kustacks.kuring.alert.domain.Alert; +import com.kustacks.kuring.alert.domain.AlertStatus; + +import java.util.List; +import java.util.Optional; + +public interface AlertQueryPort { + List findAllByStatus(AlertStatus status); + + Optional findByIdAndStatusForUpdate(Long id, AlertStatus status); +} diff --git a/src/main/java/com/kustacks/kuring/alert/application/port/out/MessageEventPort.java b/src/main/java/com/kustacks/kuring/alert/application/port/out/MessageEventPort.java new file mode 100644 index 00000000..21f3fce8 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/application/port/out/MessageEventPort.java @@ -0,0 +1,6 @@ +package com.kustacks.kuring.alert.application.port.out; + +public interface MessageEventPort { + + void sendMessageEvent(String title, String content); +} diff --git a/src/main/java/com/kustacks/kuring/alert/application/port/out/dto/AlertDto.java b/src/main/java/com/kustacks/kuring/alert/application/port/out/dto/AlertDto.java new file mode 100644 index 00000000..02175410 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/application/port/out/dto/AlertDto.java @@ -0,0 +1,29 @@ +package com.kustacks.kuring.alert.application.port.out.dto; + +import com.kustacks.kuring.alert.domain.Alert; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AlertDto { + + private Long id; + private String title; + private String content; + private LocalDateTime alertTime; + + private AlertDto(Long id, String title, String content, LocalDateTime alertTime) { + this.id = id; + this.title = title; + this.content = content; + this.alertTime = alertTime; + } + + public static AlertDto from(Alert alert) { + return new AlertDto(alert.getId(), alert.getTitle(), alert.getContent(), alert.getAlertTime()); + } +} diff --git a/src/main/java/com/kustacks/kuring/alert/application/service/AlertService.java b/src/main/java/com/kustacks/kuring/alert/application/service/AlertService.java new file mode 100644 index 00000000..3e03310b --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/application/service/AlertService.java @@ -0,0 +1,108 @@ +package com.kustacks.kuring.alert.application.service; + +import com.kustacks.kuring.alert.application.port.in.AlertCommandUseCase; +import com.kustacks.kuring.alert.application.port.in.dto.AlertCreateCommand; +import com.kustacks.kuring.alert.application.port.out.AlertCommandPort; +import com.kustacks.kuring.alert.application.port.out.AlertQueryPort; +import com.kustacks.kuring.alert.application.port.out.MessageEventPort; +import com.kustacks.kuring.alert.application.port.out.dto.AlertDto; +import com.kustacks.kuring.alert.domain.Alert; +import com.kustacks.kuring.alert.domain.AlertStatus; +import com.kustacks.kuring.common.annotation.UseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ScheduledFuture; + +@Slf4j +@UseCase +@Transactional +@RequiredArgsConstructor +public class AlertService implements AlertCommandUseCase { + + private final ConcurrentMap> taskList = new ConcurrentHashMap<>(); + private final AlertCommandPort alertCommandPort; + private final AlertQueryPort alertQueryPort; + private final MessageEventPort messageEventPort; + private final TaskScheduler taskScheduler; + private final Clock clock; + + @Override + @Transactional(readOnly = true) + @EventListener(ApplicationReadyEvent.class) + public void initAlertSchedule() { + List entryAlerts = this.findAllPending(); + entryAlerts.forEach(this::addSchedule); + } + + @Override + public void addAlertSchedule(AlertCreateCommand command) { + Alert newAlert = alertCommandPort.save( + Alert.createIfValidAlertTime( + command.title(), command.content(), command.alertTime(), LocalDateTime.now(clock) + ) + ); + + addSchedule(AlertDto.from(newAlert)); + } + + @Override + public void cancelAlertSchedule(Long id) { + ScheduledFuture scheduled = taskList.remove(id); + if (scheduled != null) { + boolean cancelComplete = scheduled.cancel(false); + + if (cancelComplete) { + alertQueryPort.findByIdAndStatusForUpdate(id, AlertStatus.PENDING) + .ifPresent(entryAlert -> cancle(id, entryAlert)); + } + } + } + + private static void cancle(Long id, Alert entryAlert) { + entryAlert.changeCanceled(); + log.info("[EntryAlert 취소] entryAlertId: {}", id); + } + + @Override + public void sendAlert(Long id) { + alertQueryPort.findByIdAndStatusForUpdate(id, AlertStatus.PENDING) + .ifPresent(entryAlert -> send(id, entryAlert)); + } + + private void send(Long id, Alert entryAlert) { + log.info("[EntryAlert 전송 시작] entryAlertId: {}", id); + messageEventPort.sendMessageEvent(entryAlert.getTitle(), entryAlert.getContent()); + entryAlert.changeCompleted(); + taskList.remove(id); + } + + private List findAllPending() { + return alertQueryPort.findAllByStatus(AlertStatus.PENDING) + .stream() + .map(AlertDto::from) + .toList(); + } + + private void addSchedule(AlertDto alertDto) { + Long alertId = alertDto.getId(); + Instant alertTime = toInstant(alertDto.getAlertTime()); + log.info("Alert 스케쥴링 추가. alertId: {}, alertTime: {}", alertId, alertTime); + ScheduledFuture scheduled = taskScheduler.schedule(() -> this.sendAlert(alertId), alertTime); + taskList.put(alertId, scheduled); + } + + private Instant toInstant(LocalDateTime localDateTime) { + return localDateTime.atZone(clock.getZone()).toInstant(); + } +} diff --git a/src/main/java/com/kustacks/kuring/alert/domain/Alert.java b/src/main/java/com/kustacks/kuring/alert/domain/Alert.java new file mode 100644 index 00000000..3dfbf781 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/domain/Alert.java @@ -0,0 +1,74 @@ +package com.kustacks.kuring.alert.domain; + +import com.google.firebase.database.annotations.NotNull; +import com.kustacks.kuring.common.domain.BaseTimeEntity; +import com.kustacks.kuring.common.exception.code.ErrorCode; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Alert extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @NotNull + @Column(name = "title", length = 128, nullable = false) + private String title; + + @Column(name = "content", length = 256) + private String content; + + @NotNull + @Column(name = "alert_time", nullable = false) + private LocalDateTime alertTime; + + @NotNull + @Enumerated(value = EnumType.STRING) + private AlertStatus status = AlertStatus.PENDING; + + private Alert(String title, String content, LocalDateTime alertTime) { + this.title = title; + this.content = content; + this.alertTime = alertTime; + } + + public static Alert createIfValidAlertTime( + String title, + String content, + LocalDateTime wakeTime, + LocalDateTime currentTime + ) { + if (currentTime.isAfter(wakeTime) || currentTime.isEqual(wakeTime)) { + throw new IllegalArgumentException(ErrorCode.DOMAIN_CANNOT_CREATE.getMessage()); + } + + return new Alert(title, content, wakeTime); + } + + public void changeCompleted() { + if(status == AlertStatus.PENDING) { + this.status = AlertStatus.COMPLETED; + return; + } + + throw new IllegalStateException(ErrorCode.DOMAIN_CANNOT_CREATE.getMessage()); + } + + public void changeCanceled() { + if(status == AlertStatus.PENDING) { + this.status = AlertStatus.CANCELED; + return; + } + + throw new IllegalStateException(ErrorCode.DOMAIN_CANNOT_CREATE.getMessage()); + } +} diff --git a/src/main/java/com/kustacks/kuring/alert/domain/AlertStatus.java b/src/main/java/com/kustacks/kuring/alert/domain/AlertStatus.java new file mode 100644 index 00000000..46c4a82d --- /dev/null +++ b/src/main/java/com/kustacks/kuring/alert/domain/AlertStatus.java @@ -0,0 +1,5 @@ +package com.kustacks.kuring.alert.domain; + +public enum AlertStatus { + PENDING, CANCELED, COMPLETED; +} diff --git a/src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java b/src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java index c3face4b..05af52b3 100644 --- a/src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java +++ b/src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java @@ -32,6 +32,9 @@ public enum ResponseCodeAndMessages { FEEDBACK_SAVE_SUCCESS(HttpStatus.OK.value(), "피드백 저장에 성공하였습니다"), FEEDBACK_SEARCH_SUCCESS(HttpStatus.OK.value(), "피드백 조회에 성공하였습니다"), + /* Alert */ + ALERT_SEARCH_SUCCESS(HttpStatus.OK.value(), "예약 알림 조회에 성공하였습니다"), + /** * ErrorCodes about auth */ diff --git a/src/main/java/com/kustacks/kuring/common/utils/converter/StringToDateTimeConverter.java b/src/main/java/com/kustacks/kuring/common/utils/converter/StringToDateTimeConverter.java new file mode 100644 index 00000000..d75ee8c3 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/common/utils/converter/StringToDateTimeConverter.java @@ -0,0 +1,48 @@ +package com.kustacks.kuring.common.utils.converter; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.regex.Pattern; + +public class StringToDateTimeConverter { + + private static final String REGEX_DATE_TIME = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$"; + private static final Pattern compiledDateTimePattern = Pattern.compile(REGEX_DATE_TIME); + + private static final String REGEX_DATE_TIME_T = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?$"; + private static final Pattern compiledDateTimeTPattern = Pattern.compile(REGEX_DATE_TIME_T); + + private static final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withLocale(Locale.KOREA); + private static final DateTimeFormatter dateTimeTFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS").withLocale(Locale.KOREA); + private static final int MAX_DATE_TIME_LENGTH = 26; + + public static LocalDateTime convert(String dateTime) { + if (dateTime == null || dateTime.isBlank()) { + throw new IllegalArgumentException("Invalid date time format: " + dateTime); + } + + if (dateTime.length() > MAX_DATE_TIME_LENGTH) { + dateTime = dateTime.substring(0, MAX_DATE_TIME_LENGTH); + } + + if (isDateTime(dateTime)) { + return LocalDateTime.parse(dateTime, dateTimeFormatter); + } + + if (isDateTimeT(dateTime)) { + LocalDateTime parsed = LocalDateTime.parse(dateTime, dateTimeTFormatter); + return parsed.withNano(0); + } + + throw new IllegalArgumentException("Invalid date time format: " + dateTime); + } + + private static boolean isDateTime(String dateTime) { + return compiledDateTimePattern.matcher(dateTime).matches(); + } + + private static boolean isDateTimeT(String dateTime) { + return compiledDateTimeTPattern.matcher(dateTime).matches(); + } +} diff --git a/src/main/java/com/kustacks/kuring/config/TimeConfig.java b/src/main/java/com/kustacks/kuring/config/TimeConfig.java new file mode 100644 index 00000000..c3fe4f55 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/config/TimeConfig.java @@ -0,0 +1,19 @@ +package com.kustacks.kuring.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; +import java.time.ZoneId; + +@Configuration +public class TimeConfig { + + private static final String ASIA_SEOUL_ZONE = "Asia/Seoul"; + + @Bean + public Clock clock() { + ZoneId seoulZoneId = ZoneId.of(ASIA_SEOUL_ZONE); + return Clock.system(seoulZoneId); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListener.java b/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListener.java index 6f502bbd..5103ccc8 100644 --- a/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListener.java +++ b/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListener.java @@ -2,6 +2,7 @@ import com.kustacks.kuring.message.adapter.in.event.dto.AdminNotificationEvent; import com.kustacks.kuring.message.adapter.in.event.dto.AdminTestNotificationEvent; +import com.kustacks.kuring.message.adapter.in.event.dto.AlertSendEvent; import com.kustacks.kuring.message.application.port.in.FirebaseWithAdminUseCase; import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; @@ -29,4 +30,11 @@ public void sendTestNotificationEvent( ) { firebaseWithAdminUseCase.sendTestNotificationByAdmin(event.toCommand()); } + + @EventListener + public void sendAlertEvent( + AlertSendEvent event + ) { + firebaseWithAdminUseCase.sendNotificationByAdmin(event.toCommand()); + } } diff --git a/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AlertSendEvent.java b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AlertSendEvent.java new file mode 100644 index 00000000..ba5e219d --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AlertSendEvent.java @@ -0,0 +1,12 @@ +package com.kustacks.kuring.message.adapter.in.event.dto; + +import com.kustacks.kuring.message.application.port.in.dto.AdminNotificationCommand; + +public record AlertSendEvent( + String title, + String content +) { + public AdminNotificationCommand toCommand() { + return new AdminNotificationCommand(title, content, ""); + } +} diff --git a/src/main/java/com/kustacks/kuring/worker/update/staff/StaffUpdater.java b/src/main/java/com/kustacks/kuring/worker/update/staff/StaffUpdater.java index dc343510..51ffc5e8 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/staff/StaffUpdater.java +++ b/src/main/java/com/kustacks/kuring/worker/update/staff/StaffUpdater.java @@ -7,14 +7,12 @@ import com.kustacks.kuring.worker.update.staff.dto.StaffScrapResults; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Slf4j @@ -31,7 +29,8 @@ public class StaffUpdater { 스크래핑 실패한 학과들을 재시도하기 위해 호출된 경우 values에 StaffDeptInfo 전체 값이 아닌, 매개변수로 들어온 값을 전달한다. */ - @Scheduled(fixedRate = 30, timeUnit = TimeUnit.DAYS) + //@Scheduled(fixedRate = 30, timeUnit = TimeUnit.DAYS) + @Deprecated(since = "2.7.3", forRemoval = true) public void update() { log.info("========== 교직원 업데이트 시작 =========="); diff --git a/src/main/java/com/kustacks/kuring/worker/update/user/UserUpdater.java b/src/main/java/com/kustacks/kuring/worker/update/user/UserUpdater.java index 70f2d3aa..8101d567 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/user/UserUpdater.java +++ b/src/main/java/com/kustacks/kuring/worker/update/user/UserUpdater.java @@ -33,6 +33,7 @@ public void questionCountReset() { // 사용자 제거 로직에 일부 오류가 있는것 같다, 정상 사용자가 제거 되었다. //@Transactional //@Scheduled(fixedRate = 30, timeUnit = TimeUnit.DAYS) + @Deprecated(since = "2.7.3", forRemoval = true) public void update() { log.info("========== 토큰 유효성 필터링 시작 =========="); @@ -42,7 +43,7 @@ public void update() { List allInvalidUsers = new LinkedList<>(); int totalPage = calculateTotalPage(totalUserCount); - for(int page = 0; page < totalPage; page++) { + for (int page = 0; page < totalPage; page++) { List invalidUsers = checkValidUserByPage(page); allInvalidUsers.addAll(invalidUsers); } diff --git a/src/main/resources/db/migration/V240803__Create_alert.sql b/src/main/resources/db/migration/V240803__Create_alert.sql new file mode 100644 index 00000000..c731c3f6 --- /dev/null +++ b/src/main/resources/db/migration/V240803__Create_alert.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS `alert` +( + alert_time datetime(6) not null, + created_at datetime(6), + id bigint not null auto_increment, + updated_at datetime(6), + title varchar(128) not null, + content varchar(256), + status enum ('PENDING','CANCELED','COMPLETED'), + primary key (id) +) engine = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_unicode_ci; diff --git a/src/test/java/com/kustacks/kuring/acceptance/AdminAcceptanceTest.java b/src/test/java/com/kustacks/kuring/acceptance/AdminAcceptanceTest.java index 70d99747..e99c3051 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/AdminAcceptanceTest.java +++ b/src/test/java/com/kustacks/kuring/acceptance/AdminAcceptanceTest.java @@ -2,17 +2,23 @@ import com.kustacks.kuring.admin.adapter.in.web.dto.RealNotificationRequest; import com.kustacks.kuring.admin.adapter.in.web.dto.TestNotificationRequest; +import com.kustacks.kuring.alert.adapter.in.web.dto.AlertCreateRequest; import com.kustacks.kuring.support.IntegrationTestSupport; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import static com.kustacks.kuring.acceptance.AdminStep.사용자_피드백_조회_요청; -import static com.kustacks.kuring.acceptance.AdminStep.피드백_조회_확인; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +import static com.kustacks.kuring.acceptance.AdminStep.*; import static com.kustacks.kuring.acceptance.AuthStep.로그인_되어_있음; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -20,6 +26,21 @@ @DisplayName("인수 : 관리자") class AdminAcceptanceTest extends IntegrationTestSupport { + AlertCreateRequest alertCreateCommand; + + @Autowired + Clock clock; + + @Override + @BeforeEach + public void setUp() { + super.setUp(); + LocalDateTime expiredTime = LocalDateTime.now(clock).plus(1, ChronoUnit.HOURS); + alertCreateCommand = new AlertCreateRequest( + "title", "content", expiredTime.toString() + ); + } + /** * given : 사전에 등록된 어드민가 피드백들이 이다 * when : 어드민이 피드백 조회시 @@ -100,6 +121,80 @@ void role_root_admin_create_real_notification() { ); } + /** + * Given : 등록된 ROLE_ROOT의 Admin이 있다. + * When : 원하는 시간에 예약 알림을 등록한다 + * Then : 성공적으로 등록된다. + */ + @DisplayName("[v2] Admin은 예약 알림을 생성할 수 있다") + @Test + void add_alert_test() { + // given + String accessToken = 로그인_되어_있음(ADMIN_LOGIN_ID, ADMIN_PASSWORD); + + // when + var 알림_예약_응답 = 알림_예약(accessToken, alertCreateCommand); + + // then + assertAll( + () -> assertThat(알림_예약_응답.statusCode()).isEqualTo(HttpStatus.OK.value()), + () -> assertThat(알림_예약_응답.jsonPath().getInt("code")).isEqualTo(200), + () -> assertThat(알림_예약_응답.jsonPath().getString("message")).isEqualTo("실제 공지 생성에 성공하였습니다"), + () -> assertThat(알림_예약_응답.jsonPath().getString("data")).isNull() + ); + } + + /** + * Given : Admin이 등록한 예약 알림이 있다 + * When : 예약 알림을 조회한다 + * Then : 예약되어 있던 모든 알림을 조회한다 + */ + @DisplayName("[v2] Admin은 예약된 모든 알림을 조회할 수 있다") + @Test + void lookup_all_alert_test() { + // given + String accessToken = 로그인_되어_있음(ADMIN_LOGIN_ID, ADMIN_PASSWORD); + 알림_예약(accessToken, alertCreateCommand); + + // when + var response = 예약_알림_조회(accessToken); + + // then + assertAll( + () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()), + () -> assertThat(response.jsonPath().getInt("code")).isEqualTo(200), + () -> assertThat(response.jsonPath().getString("message")).isEqualTo("예약 알림 조회에 성공하였습니다"), + () -> assertThat(response.jsonPath().getString("data")).contains("id", "title", "content", "status", "wakeTime") + ); + } + + /** + * Given : Admin이 등록한 예약 알림이 있다 + * When : 특정 예약 알림을 삭제한다 + * Then : 성공적으로 삭제된다. + */ + @DisplayName("[v2] Admin은 예약 알림을 삭제할 수 있다") + @Test + void delete_alert_test() { + // given + String accessToken = 로그인_되어_있음(ADMIN_LOGIN_ID, ADMIN_PASSWORD); + 알림_예약(accessToken, alertCreateCommand); + int alertId = 예약_알림_조회(accessToken).jsonPath().getInt("data[0].id"); + + // when + var 예약_알림_삭제_응답 = 예약_알림_삭제(accessToken, alertId); + + // then + assertThat(예약_알림_삭제_응답.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); + var response = 예약_알림_조회(accessToken); + assertAll( + () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()), + () -> assertThat(response.jsonPath().getInt("code")).isEqualTo(200), + () -> assertThat(response.jsonPath().getString("message")).isEqualTo("예약 알림 조회에 성공하였습니다"), + () -> assertThat(response.jsonPath().getString("data[0].status")).isEqualTo("CANCELED") + ); + } + /** * Given : 등록된 ROLE_ROOT의 Admin이 있다. * When : ROLE_ROOT의 API에 접근시 diff --git a/src/test/java/com/kustacks/kuring/acceptance/AdminStep.java b/src/test/java/com/kustacks/kuring/acceptance/AdminStep.java index ff3295ce..82e06712 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/AdminStep.java +++ b/src/test/java/com/kustacks/kuring/acceptance/AdminStep.java @@ -1,5 +1,6 @@ package com.kustacks.kuring.acceptance; +import com.kustacks.kuring.alert.adapter.in.web.dto.AlertCreateRequest; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -29,4 +30,35 @@ public class AdminStep { .then().log().all() .extract(); } + + public static ExtractableResponse 알림_예약(String accessToken, AlertCreateRequest alertCreateCommand) { + return RestAssured + .given().log().all() + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(alertCreateCommand) + .when().post("/api/v2/admin/alerts") + .then().log().all() + .extract(); + } + + public static ExtractableResponse 예약_알림_조회(String accessToken) { + return RestAssured + .given().log().all() + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().get("/api/v2/admin/alerts?page=0&size=10") + .then().log().all() + .extract(); + } + + public static ExtractableResponse 예약_알림_삭제(String accessToken, int alertId) { + return RestAssured + .given().log().all() + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().delete("/api/v2/admin/alerts/{id}", alertId) + .then().log().all() + .extract(); + } } diff --git a/src/test/java/com/kustacks/kuring/alert/application/service/AlertServiceTest.java b/src/test/java/com/kustacks/kuring/alert/application/service/AlertServiceTest.java new file mode 100644 index 00000000..2f6d5e8d --- /dev/null +++ b/src/test/java/com/kustacks/kuring/alert/application/service/AlertServiceTest.java @@ -0,0 +1,80 @@ +package com.kustacks.kuring.alert.application.service; + +import com.kustacks.kuring.alert.adapter.out.persistence.AlertRepository; +import com.kustacks.kuring.alert.application.port.in.dto.AlertCreateCommand; +import com.kustacks.kuring.alert.domain.Alert; +import com.kustacks.kuring.alert.domain.AlertStatus; +import com.kustacks.kuring.support.IntegrationTestSupport; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertAll; + +class AlertServiceTest extends IntegrationTestSupport { + + @Autowired + AlertService alertService; + + @Autowired + AlertRepository alertRepository; + + @Autowired + Clock clock; + + @DisplayName("알림을 성공적으로 등록한다") + @Test + void creat_alert() { + // given + LocalDateTime expiredTime = LocalDateTime.now(clock).plus(1, ChronoUnit.SECONDS); + AlertCreateCommand alertCreateCommand = new AlertCreateCommand( + "title", "content", + expiredTime + ); + + // when + alertService.addAlertSchedule(alertCreateCommand); + + // then + List alertList = alertRepository.findAllByStatus(AlertStatus.PENDING); + assertAll( + () -> Assertions.assertThat(alertList).hasSize(1), + () -> Assertions.assertThat(alertList.get(0).getTitle()).isEqualTo("title"), + () -> Assertions.assertThat(alertList.get(0).getContent()).isEqualTo("content"), + () -> Assertions.assertThat(alertList.get(0).getAlertTime().truncatedTo(ChronoUnit.MICROS)) + .isEqualTo(expiredTime.truncatedTo(ChronoUnit.MICROS)) + ); + } + + @DisplayName("알림을 성공적으로 취소한다") + @Test + void cancel_alert() { + // given + LocalDateTime expiredTime = LocalDateTime.now(clock).plus(1, ChronoUnit.SECONDS); + AlertCreateCommand alertCreateCommand = new AlertCreateCommand( + "title", "content", + expiredTime + ); + alertService.addAlertSchedule(alertCreateCommand); + Long alertId = alertRepository.findAllByStatus(AlertStatus.PENDING).get(0).getId(); + + // when + alertService.cancelAlertSchedule(alertId); + + // then + List alertList = alertRepository.findAllByStatus(AlertStatus.CANCELED); + assertAll( + () -> Assertions.assertThat(alertList).hasSize(1), + () -> Assertions.assertThat(alertList.get(0).getTitle()).isEqualTo("title"), + () -> Assertions.assertThat(alertList.get(0).getContent()).isEqualTo("content"), + () -> Assertions.assertThat(alertList.get(0).getAlertTime().truncatedTo(ChronoUnit.MICROS)) + .isEqualTo(expiredTime.truncatedTo(ChronoUnit.MICROS)) + ); + } +} diff --git a/src/test/java/com/kustacks/kuring/alert/domain/AlertTest.java b/src/test/java/com/kustacks/kuring/alert/domain/AlertTest.java new file mode 100644 index 00000000..a499e25e --- /dev/null +++ b/src/test/java/com/kustacks/kuring/alert/domain/AlertTest.java @@ -0,0 +1,73 @@ +package com.kustacks.kuring.alert.domain; + +import org.assertj.core.api.ThrowableAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +@DisplayName("도메인 : Alert") +class AlertTest { + + LocalDateTime now; + LocalDateTime wakeTime; + + @BeforeEach + void setUp() { + // given + now = LocalDateTime.of(2024, 1, 19, 17, 27, 5); + wakeTime = now.plusMinutes(10); + } + + @DisplayName("Alert 예약시간은 현 시간보다 미래여야 한다") + @Test + void creat_alert() { + // when, then + assertThatCode(() -> Alert.createIfValidAlertTime("title", "contents", wakeTime, now)) + .doesNotThrowAnyException(); + } + + @DisplayName("Alert 예약시간이 현 시간과 같거나 과거인 경우 예외를 발생시킨다") + @Test + void exception_alert() { + // when + ThrowableAssert.ThrowingCallable actual = () -> Alert.createIfValidAlertTime("title", "contents", now, now); + + // then + assertThatThrownBy(actual) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("Alert 의 상태를 변경한다") + @Test + void change_alert_status() { + // given + Alert alert = Alert.createIfValidAlertTime("title", "contents", wakeTime, now); + + // when + alert.changeCompleted(); + + // then + assertThat(alert.getStatus()).isEqualTo(AlertStatus.COMPLETED); + } + + @DisplayName("이미 requested 상태의 Alert 상태를 변경하려 하는 경우 예외가 발생한다") + @Test + void change_alert_status_exception() { + // given + Alert alert = Alert.createIfValidAlertTime("title", "contents", wakeTime, now); + alert.changeCompleted(); + + // when + ThrowableAssert.ThrowingCallable actual = () -> alert.changeCompleted(); + + // then + assertThatThrownBy(actual) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/com/kustacks/kuring/common/utils/converter/StringToDateTimeConverterTest.java b/src/test/java/com/kustacks/kuring/common/utils/converter/StringToDateTimeConverterTest.java new file mode 100644 index 00000000..ae9e1671 --- /dev/null +++ b/src/test/java/com/kustacks/kuring/common/utils/converter/StringToDateTimeConverterTest.java @@ -0,0 +1,50 @@ +package com.kustacks.kuring.common.utils.converter; + +import org.assertj.core.api.ThrowableAssert; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.LocalDateTime; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StringToDateTimeConverterTest { + + @DisplayName("날짜 시간이 yyyy-MM-dd HH:mm:ss 형태로 주어지면 LocalDateTime 으로 변환한다") + @ParameterizedTest + @MethodSource("stringLocalDateInputProvider") + void date_time_test(String stringDate, LocalDateTime expected) { + // when + LocalDateTime convertedDateTime = StringToDateTimeConverter.convert(stringDate); + + // then + assertThat(convertedDateTime).isEqualTo(expected); + } + + @DisplayName("지정된 형식의 날짜 시간 구조가 아닌 경우 예외가 발생한다") + @CsvSource({"2023:04:03", "2023-4-3", "04-03", "-04-03", + "2023-04-03 00:00", "2023-04-03 00", "2023-04-03 00:00:00:00", "00:00:00", "2023-04-0300:00:12"}) + @ParameterizedTest + void date_time_convert_fail(String dateTime) { + // when + ThrowableAssert.ThrowingCallable actual = () -> StringToDateTimeConverter.convert(dateTime); + + // then + assertThatThrownBy(actual) + .isInstanceOf(IllegalArgumentException.class); + } + + private static Stream stringLocalDateInputProvider() { + return Stream.of( + Arguments.of("2023-04-03 00:00:12", LocalDateTime.of(2023, 4, 3, 0, 0, 12)), + Arguments.of("2024-08-03T20:01:27.454996", LocalDateTime.of(2024, 8, 3, 20, 1, 27)), + Arguments.of("2024-08-04T21:57:23.1166969", LocalDateTime.of(2024, 8, 4, 21, 57, 23)), + Arguments.of("2024-08-04T21:57:23.116696917", LocalDateTime.of(2024, 8, 4, 21, 57, 23)) + ); + } +}