diff --git a/src/main/java/kr/mywork/common/api/components/filter/HttpLoggingFilter.java b/src/main/java/kr/mywork/common/api/components/filter/HttpLoggingFilter.java index 47133215..a03cbfb0 100644 --- a/src/main/java/kr/mywork/common/api/components/filter/HttpLoggingFilter.java +++ b/src/main/java/kr/mywork/common/api/components/filter/HttpLoggingFilter.java @@ -13,6 +13,7 @@ import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletOutputStream; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; @@ -37,10 +38,18 @@ public class HttpLoggingFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + final HttpServletRequest httpServletRequest = (HttpServletRequest)request; + final HttpServletResponse httpServletResponse = (HttpServletResponse)response; + + if (isAcceptTextEventStream(httpServletRequest)) { + chain.doFilter(request, response); + return; + } + HttpRequestBodyCachedWrapper requestWrapper = - new HttpRequestBodyCachedWrapper((HttpServletRequest)request); + new HttpRequestBodyCachedWrapper(httpServletRequest); HttpResponseBodyCachedWrapper responseWrapper = - new HttpResponseBodyCachedWrapper((HttpServletResponse)response); + new HttpResponseBodyCachedWrapper(httpServletResponse); logRequest(requestWrapper); @@ -50,6 +59,10 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha writeResponseBody(responseWrapper, response); } + private boolean isAcceptTextEventStream(final HttpServletRequest httpServletRequest) { + return MediaType.TEXT_EVENT_STREAM_VALUE.equalsIgnoreCase(httpServletRequest.getHeader(HttpHeaders.ACCEPT)); + } + private void logRequest(HttpRequestBodyCachedWrapper request) throws IOException { String url = request.getRequestURI(); String method = request.getMethod(); @@ -76,7 +89,7 @@ private String getRequestHeadersAsString(HttpServletRequest request) { return headers.toString().trim(); } - private String getRequestBodyAsString(final HttpRequestBodyCachedWrapper request) throws IOException { + private String getRequestBodyAsString(final HttpRequestBodyCachedWrapper request) { return new String(request.getCachedBody(), StandardCharsets.UTF_8); } @@ -144,6 +157,14 @@ private String getResponseHeadersAsString(Map responseHeadersMap private void writeResponseBody(HttpResponseBodyCachedWrapper cachedResponse, ServletResponse response) throws IOException { final byte[] responseBody = cachedResponse.getCachedBody(); - response.getOutputStream().write(responseBody); + + try (final ServletOutputStream outputStream = response.getOutputStream()) { + outputStream.write(responseBody); + } catch (IOException e) { + log.error("Error writing response body", e); + throw e; + } finally { + cachedResponse.setContentLength(responseBody.length); + } } } diff --git a/src/main/java/kr/mywork/common/sse/SseEmitters.java b/src/main/java/kr/mywork/common/sse/SseEmitters.java new file mode 100644 index 00000000..3497067b --- /dev/null +++ b/src/main/java/kr/mywork/common/sse/SseEmitters.java @@ -0,0 +1,103 @@ +package kr.mywork.common.sse; + +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import kr.mywork.domain.notification.errors.NotificationEmitterNotFoundException; +import kr.mywork.domain.notification.errors.NotificationErrorType; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SseEmitters { + + // key : memberId + private final Map sseEmitters = new ConcurrentHashMap<>(); + + public SseEmitter add(UUID clientId) { + final SseEmitter sseEmitter = new SseEmitter(600 * 1000L); + + if (sseEmitters.containsKey(clientId)) { + return sseEmitters.get(clientId); + } else { + sseEmitters.put(clientId, sseEmitter); + } + + sseEmitter.onCompletion(() -> { + log.info("onCompletion sseEmitter : {}", sseEmitter); + sseEmitters.remove(clientId); + }); + sseEmitter.onTimeout(() -> { + log.info("onTimeout sseEmitter : {}", sseEmitter); + sseEmitters.remove(clientId); + }); + sseEmitter.onError(e -> { + log.info("onError sseEmitter : {}, error: {}", sseEmitter, e.getMessage()); + this.sseEmitters.remove(clientId); + }); + + log.info("added sseEmitter id : {}, sseEmitters size: {}", clientId, sseEmitters.size()); + return sseEmitter; + } + + public SseEmitter remove(UUID memberId) { + if (!sseEmitters.containsKey(memberId)) { + throw new NotificationEmitterNotFoundException(NotificationErrorType.EMITTER_NOT_FOUND); + } + + log.debug("removing sseEmitter for memberId: {}", memberId); + + final SseEmitter removedEmitter = sseEmitters.remove(memberId); + removedEmitter.complete(); + + return removedEmitter; + } + + public boolean send(UUID clientId, String eventName, T data) { + if (!sseEmitters.containsKey(clientId)) { + return false; + } + + SseEmitter emitter = sseEmitters.get(clientId); + + try { + emitter.send( + SseEmitter.event() + .name(eventName) + .data(data) + .build()); + + return true; + } catch (Exception exception) { + log.info("send sseEmitter error : {}", exception.getMessage()); + sseEmitters.remove(clientId); + emitter.completeWithError(exception); + } + + return false; + } + + @Scheduled(fixedDelay = 30 * 1000L) + public void ping() { + for (Map.Entry emitterEntry : sseEmitters.entrySet()) { + final SseEmitter emitter = emitterEntry.getValue(); + try { + emitter.send(SseEmitter.event().name("ping").data("ping")); + } catch (IOException e) { + log.error("Error sending ping to emitter for clientId {}: {}", emitterEntry.getKey(), e.getMessage()); + sseEmitters.remove(emitterEntry.getKey()); + emitter.completeWithError(e); + } catch (Exception e) { + log.error("Unexpected error while sending ping: {}", e.getMessage()); + sseEmitters.remove(emitterEntry.getKey()); + emitter.completeWithError(e); + } + } + } +} diff --git a/src/main/java/kr/mywork/domain/activityLog/listener/ActivityLogListener.java b/src/main/java/kr/mywork/domain/activityLog/listener/ActivityLogTxListener.java similarity index 64% rename from src/main/java/kr/mywork/domain/activityLog/listener/ActivityLogListener.java rename to src/main/java/kr/mywork/domain/activityLog/listener/ActivityLogTxListener.java index f181c5e6..7944d6ed 100644 --- a/src/main/java/kr/mywork/domain/activityLog/listener/ActivityLogListener.java +++ b/src/main/java/kr/mywork/domain/activityLog/listener/ActivityLogTxListener.java @@ -2,58 +2,57 @@ import java.util.List; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.DeleteEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogDeleteEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; import kr.mywork.domain.activityLog.model.ActivityLog; import kr.mywork.domain.activityLog.model.LogDetail; -import kr.mywork.domain.activityLog.repository.ActivityLogRepository; import kr.mywork.domain.activityLog.service.ActivityLogService; import kr.mywork.domain.activityLog.service.LogDetailService; -import kr.mywork.domain.company.repository.CompanyRepository; import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor -public class ActivityLogListener { +public class ActivityLogTxListener { - private final ActivityLogRepository activityLogRepository; - private final CompanyRepository companyRepository; private final LogDetailService logDetailService; private final ActivityLogService activityLogService; + @Async(value = "eventTaskExecutor") @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleProjectCreated(CreateEventObject event) { + public void handleProjectCreated(final ActivityLogCreateEvent event) { ActivityLog activityLog = activityLogService.makeCreatedActivityLog(event); - activityLogRepository.save(activityLog); - + activityLogService.save(activityLog); } + @Async(value = "eventTaskExecutor") @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleProjectModified(ModifyEventObject event) { + public void handleProjectModified(final ActivityModifyEvent event) { ActivityLog activityLog = activityLogService.makeModifiedActivityLog(event); - ActivityLog savedLog = activityLogRepository.save(activityLog); + ActivityLog savedLog = activityLogService.save(activityLog); List diffValue = logDetailService.makeLogDetails(event, savedLog.getId()); logDetailService.saveAll(diffValue); } + @Async(value = "eventTaskExecutor") @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleProjectDeleted(DeleteEventObject event) { + public void handleProjectDeleted(final ActivityLogDeleteEvent event) { ActivityLog activityLog = activityLogService.makeDeletedActivityLog(event); - activityLogRepository.save(activityLog); + activityLogService.save(activityLog); } } diff --git a/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ActivityLogCreateEvent.java b/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ActivityLogCreateEvent.java new file mode 100644 index 00000000..131116cc --- /dev/null +++ b/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ActivityLogCreateEvent.java @@ -0,0 +1,6 @@ +package kr.mywork.domain.activityLog.listener.eventObject; + +import kr.mywork.common.auth.components.dto.LoginMemberDetail; + +public record ActivityLogCreateEvent(Object created, LoginMemberDetail loginMemberDetail) { +} diff --git a/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ActivityLogDeleteEvent.java b/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ActivityLogDeleteEvent.java new file mode 100644 index 00000000..744d9e54 --- /dev/null +++ b/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ActivityLogDeleteEvent.java @@ -0,0 +1,7 @@ +package kr.mywork.domain.activityLog.listener.eventObject; + +import kr.mywork.common.auth.components.dto.LoginMemberDetail; + +public record ActivityLogDeleteEvent(Object deleted, LoginMemberDetail loginMemberDetail) { + +} diff --git a/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ActivityModifyEvent.java b/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ActivityModifyEvent.java new file mode 100644 index 00000000..e6ad8fef --- /dev/null +++ b/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ActivityModifyEvent.java @@ -0,0 +1,7 @@ +package kr.mywork.domain.activityLog.listener.eventObject; + +import kr.mywork.common.auth.components.dto.LoginMemberDetail; + +public record ActivityModifyEvent(Object before, Object after, LoginMemberDetail loginMemberDetail) { + +} diff --git a/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/CreateEventObject.java b/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/CreateEventObject.java deleted file mode 100644 index 3cdd2d4f..00000000 --- a/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/CreateEventObject.java +++ /dev/null @@ -1,14 +0,0 @@ -package kr.mywork.domain.activityLog.listener.eventObject; - -import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Getter -public class CreateEventObject { - - private final Object created; - private final LoginMemberDetail loginMemberDetail; - -} diff --git a/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/DeleteEventObject.java b/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/DeleteEventObject.java deleted file mode 100644 index 7dd89825..00000000 --- a/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/DeleteEventObject.java +++ /dev/null @@ -1,14 +0,0 @@ -package kr.mywork.domain.activityLog.listener.eventObject; - -import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Getter -public class DeleteEventObject { - - private final Object deleted; - private final LoginMemberDetail loginMemberDetail; - -} diff --git a/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ModifyEventObject.java b/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ModifyEventObject.java deleted file mode 100644 index cbc0ce87..00000000 --- a/src/main/java/kr/mywork/domain/activityLog/listener/eventObject/ModifyEventObject.java +++ /dev/null @@ -1,15 +0,0 @@ -package kr.mywork.domain.activityLog.listener.eventObject; - -import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Getter -public class ModifyEventObject { - - private final Object before; - private final Object after; - private final LoginMemberDetail loginMemberDetail; - -} diff --git a/src/main/java/kr/mywork/domain/activityLog/service/ActivityLogService.java b/src/main/java/kr/mywork/domain/activityLog/service/ActivityLogService.java index 6184010b..c4e74ac7 100644 --- a/src/main/java/kr/mywork/domain/activityLog/service/ActivityLogService.java +++ b/src/main/java/kr/mywork/domain/activityLog/service/ActivityLogService.java @@ -7,9 +7,9 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.DeleteEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogDeleteEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; import kr.mywork.domain.activityLog.model.ActionType; import kr.mywork.domain.activityLog.model.ActivityLog; import kr.mywork.domain.activityLog.model.LogDetail; @@ -38,65 +38,69 @@ public class ActivityLogService { private final LogDetailService logDetailService; + public ActivityLog save(final ActivityLog activityLog) { + return activityLogRepository.save(activityLog); + } + // 액티비티 로그 객체 생성 - 생성 액션 전용 - public ActivityLog makeCreatedActivityLog(CreateEventObject event) { - TargetType targetType = determineTargetType(event.getCreated()); + public ActivityLog makeCreatedActivityLog(ActivityLogCreateEvent event) { + TargetType targetType = determineTargetType(event.created()); // 적절한 타입으로 캐스팅 - LocalDateTime createdAt = extractCreatedDateTime(event.getCreated()); - UUID targetId = extractTargetId(event.getCreated()); + LocalDateTime createdAt = extractCreatedDateTime(event.created()); + UUID targetId = extractTargetId(event.created()); return new ActivityLog( createdAt, ActionType.CREATE, targetType, targetId, - event.getLoginMemberDetail().memberId(), - event.getLoginMemberDetail().memberName(), - event.getLoginMemberDetail().companyId(), - event.getLoginMemberDetail().companyName(), - event.getLoginMemberDetail().companyType() + event.loginMemberDetail().memberId(), + event.loginMemberDetail().memberName(), + event.loginMemberDetail().companyId(), + event.loginMemberDetail().companyName(), + event.loginMemberDetail().companyType() ); } // 액티비티 로그 객체 생성 - 수정 액션 전용 - public ActivityLog makeModifiedActivityLog(ModifyEventObject event) { - TargetType targetType = determineTargetType(event.getBefore()); + public ActivityLog makeModifiedActivityLog(ActivityModifyEvent event) { + TargetType targetType = determineTargetType(event.before()); - LocalDateTime modifiedAt = extractModifiedDateTime(event.getAfter()); - UUID targetId = extractTargetId(event.getAfter()); + LocalDateTime modifiedAt = extractModifiedDateTime(event.after()); + UUID targetId = extractTargetId(event.after()); return new ActivityLog( modifiedAt, ActionType.MODIFY, targetType, targetId, - event.getLoginMemberDetail().memberId(), - event.getLoginMemberDetail().memberName(), - event.getLoginMemberDetail().companyId(), - event.getLoginMemberDetail().companyName(), - event.getLoginMemberDetail().companyType() + event.loginMemberDetail().memberId(), + event.loginMemberDetail().memberName(), + event.loginMemberDetail().companyId(), + event.loginMemberDetail().companyName(), + event.loginMemberDetail().companyType() ); } // 액티비티 로그 객체 생성 - 삭제 액션 전용 - public ActivityLog makeDeletedActivityLog(DeleteEventObject event) { - TargetType targetType = determineTargetType(event.getDeleted()); + public ActivityLog makeDeletedActivityLog(final ActivityLogDeleteEvent event) { + TargetType targetType = determineTargetType(event.deleted()); // 적절한 타입으로 캐스팅 - LocalDateTime deletedAt = extractModifiedDateTime(event.getDeleted()); - UUID targetId = extractTargetId(event.getDeleted()); + LocalDateTime deletedAt = extractModifiedDateTime(event.deleted()); + UUID targetId = extractTargetId(event.deleted()); return new ActivityLog( deletedAt, ActionType.DELETE, targetType, targetId, - event.getLoginMemberDetail().memberId(), - event.getLoginMemberDetail().memberName(), - event.getLoginMemberDetail().companyId(), - event.getLoginMemberDetail().companyName(), - event.getLoginMemberDetail().companyType() + event.loginMemberDetail().memberId(), + event.loginMemberDetail().memberName(), + event.loginMemberDetail().companyId(), + event.loginMemberDetail().companyName(), + event.loginMemberDetail().companyType() ); } diff --git a/src/main/java/kr/mywork/domain/activityLog/service/LogDetailService.java b/src/main/java/kr/mywork/domain/activityLog/service/LogDetailService.java index e0e4cb91..ced3cacc 100644 --- a/src/main/java/kr/mywork/domain/activityLog/service/LogDetailService.java +++ b/src/main/java/kr/mywork/domain/activityLog/service/LogDetailService.java @@ -9,7 +9,7 @@ import org.springframework.stereotype.Service; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; import kr.mywork.domain.activityLog.model.FieldType; import kr.mywork.domain.activityLog.model.LogDetail; import kr.mywork.domain.activityLog.repository.LogDetailRepository; @@ -30,9 +30,9 @@ public class LogDetailService { private final LogDetailRepository logDetailRepository; - public List makeLogDetails(ModifyEventObject event, UUID activityLogId) { + public List makeLogDetails(ActivityModifyEvent event, UUID activityLogId) { - Class clazz = event.getBefore().getClass(); + Class clazz = event.before().getClass(); List diffValue = new ArrayList<>(); @@ -40,8 +40,8 @@ public List makeLogDetails(ModifyEventObject event, UUID activityLogI field.setAccessible(true); try { - Object beforeValue = field.get(event.getBefore()); - Object afterValue = field.get(event.getAfter()); + Object beforeValue = field.get(event.before()); + Object afterValue = field.get(event.after()); if (!java.util.Objects.equals(beforeValue, afterValue)) { final String fieldName = field.getName(); @@ -50,7 +50,7 @@ public List makeLogDetails(ModifyEventObject event, UUID activityLogI fieldName.equals("deleted") || fieldName.equals("createdAt") || fieldName.equals("id")) continue; - FieldType fieldType = typeCheckAndReturn(event.getBefore(), field.getName()); + FieldType fieldType = typeCheckAndReturn(event.before(), field.getName()); diffValue.add(new LogDetail(activityLogId, fieldType, convertToString(beforeValue), convertToString(afterValue))); } diff --git a/src/main/java/kr/mywork/domain/company/service/CompanyService.java b/src/main/java/kr/mywork/domain/company/service/CompanyService.java index dda9cea3..620bc8fc 100644 --- a/src/main/java/kr/mywork/domain/company/service/CompanyService.java +++ b/src/main/java/kr/mywork/domain/company/service/CompanyService.java @@ -11,9 +11,9 @@ import com.fasterxml.uuid.Generators; import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.DeleteEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogDeleteEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; import kr.mywork.domain.company.errors.CompanyAlreadyExistException; import kr.mywork.domain.company.errors.CompanyErrorType; import kr.mywork.domain.company.errors.CompanyIdNotFoundException; @@ -59,7 +59,7 @@ public UUID createCompany(final CompanyCreateRequest companyCreateRequest, Login final Company savedCompany = companyRepository.save(companyCreateRequest); - eventPublisher.publishEvent(new CreateEventObject(savedCompany, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogCreateEvent(savedCompany, loginMemberDetail)); return savedCompany.getId(); } @@ -71,7 +71,7 @@ public UUID deleteCompany(final UUID companyId, LoginMemberDetail loginMemberDet company.setDeleted(true); - eventPublisher.publishEvent(new DeleteEventObject(company, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogDeleteEvent(company, loginMemberDetail)); return company.getId(); } @@ -85,7 +85,7 @@ public UUID updateCompany(CompanyUpdateRequest companyUpdateRequest, LoginMember company.updateFrom(companyUpdateRequest); - eventPublisher.publishEvent(new ModifyEventObject(before, company, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityModifyEvent(before, company, loginMemberDetail)); return company.getId(); } diff --git a/src/main/java/kr/mywork/domain/member/service/MemberService.java b/src/main/java/kr/mywork/domain/member/service/MemberService.java index 9476c1e4..48063db7 100644 --- a/src/main/java/kr/mywork/domain/member/service/MemberService.java +++ b/src/main/java/kr/mywork/domain/member/service/MemberService.java @@ -12,9 +12,9 @@ import org.springframework.transaction.annotation.Transactional; import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.DeleteEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogDeleteEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; import kr.mywork.domain.company.service.dto.response.MemberDetailResponse; import kr.mywork.domain.member.errors.*; import kr.mywork.domain.member.model.Member; @@ -25,15 +25,6 @@ import kr.mywork.domain.member.service.dto.response.MemberSelectResponse; import kr.mywork.interfaces.member.controller.dto.request.ResetPasswordWebRequest; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -77,7 +68,7 @@ public UUID createMember(MemberCreateRequest request, LoginMemberDetail loginMem final Member savedMember = memberRepository.save(member); - eventPublisher.publishEvent(new CreateEventObject(savedMember, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogCreateEvent(savedMember, loginMemberDetail)); return savedMember.getId(); } @@ -90,7 +81,7 @@ public UUID deleteMember(UUID memberId, LoginMemberDetail loginMemberDetail) { //더티체킹 member.softDelete(); - eventPublisher.publishEvent(new DeleteEventObject(member, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogDeleteEvent(member, loginMemberDetail)); return member.getId(); } @@ -113,7 +104,7 @@ public UUID updateMember(final MemberUpdateRequest memberUpdateRequest, LoginMem memberUpdateRequest.birthDate(), memberUpdateRequest.deleted()); - eventPublisher.publishEvent(new ModifyEventObject(before, member, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityModifyEvent(before, member, loginMemberDetail)); return member.getId(); } diff --git a/src/main/java/kr/mywork/domain/notification/errors/NotificationEmitterNotFoundException.java b/src/main/java/kr/mywork/domain/notification/errors/NotificationEmitterNotFoundException.java new file mode 100644 index 00000000..9cb46f0d --- /dev/null +++ b/src/main/java/kr/mywork/domain/notification/errors/NotificationEmitterNotFoundException.java @@ -0,0 +1,7 @@ +package kr.mywork.domain.notification.errors; + +public class NotificationEmitterNotFoundException extends NotificationException { + public NotificationEmitterNotFoundException(final NotificationErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/kr/mywork/domain/notification/errors/NotificationErrorCode.java b/src/main/java/kr/mywork/domain/notification/errors/NotificationErrorCode.java index 2a25783f..b6c553d7 100644 --- a/src/main/java/kr/mywork/domain/notification/errors/NotificationErrorCode.java +++ b/src/main/java/kr/mywork/domain/notification/errors/NotificationErrorCode.java @@ -1,5 +1,6 @@ package kr.mywork.domain.notification.errors; public enum NotificationErrorCode { - ERROR_NOTIFICATION_TYPE01; + ERROR_NOTIFICATION_TYPE01, + ERROR_NOTIFICATION_TYPE02; } diff --git a/src/main/java/kr/mywork/domain/notification/errors/NotificationErrorType.java b/src/main/java/kr/mywork/domain/notification/errors/NotificationErrorType.java index 81c4d483..930ffbc4 100644 --- a/src/main/java/kr/mywork/domain/notification/errors/NotificationErrorType.java +++ b/src/main/java/kr/mywork/domain/notification/errors/NotificationErrorType.java @@ -6,7 +6,8 @@ @Getter @RequiredArgsConstructor public enum NotificationErrorType { - NOTIFICATION_NOT_FOUND(NotificationErrorCode.ERROR_NOTIFICATION_TYPE01, "존재하지 않는 알림입니다."); + NOTIFICATION_NOT_FOUND(NotificationErrorCode.ERROR_NOTIFICATION_TYPE01, "존재하지 않는 알림입니다."), + EMITTER_NOT_FOUND(NotificationErrorCode.ERROR_NOTIFICATION_TYPE02, "사용자의 에미터가 존재하지 않습니다."); private final NotificationErrorCode errorCode; private final String message; diff --git a/src/main/java/kr/mywork/domain/notification/listener/event/NotificationCreateEvent.java b/src/main/java/kr/mywork/domain/notification/listener/event/NotificationCreateEvent.java new file mode 100644 index 00000000..03005fbc --- /dev/null +++ b/src/main/java/kr/mywork/domain/notification/listener/event/NotificationCreateEvent.java @@ -0,0 +1,12 @@ +package kr.mywork.domain.notification.listener.event; + +import java.time.LocalDateTime; +import java.util.UUID; + +import kr.mywork.domain.notification.model.NotificationActionType; +import kr.mywork.domain.notification.model.TargetType; + +public record NotificationCreateEvent(UUID authorId, String authorName, String content, String actorName, UUID actorId, + TargetType targetType, UUID targetId, NotificationActionType notificationActionType, + LocalDateTime modifiedAt, UUID projectId, UUID projectStepId) { +} diff --git a/src/main/java/kr/mywork/domain/notification/listener/event/NotificationTxEventListener.java b/src/main/java/kr/mywork/domain/notification/listener/event/NotificationTxEventListener.java new file mode 100644 index 00000000..6bdd31df --- /dev/null +++ b/src/main/java/kr/mywork/domain/notification/listener/event/NotificationTxEventListener.java @@ -0,0 +1,47 @@ +package kr.mywork.domain.notification.listener.event; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import kr.mywork.domain.notification.service.NotificationService; +import kr.mywork.domain.notification.service.RealTimeNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationTxEventListener { + + private final RealTimeNotificationService realTimeNotificationService; + private final NotificationService notificationService; + + @Async(value = "eventTaskExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleNotificationCreateEvent(final NotificationCreateEvent event) { + realTimeNotificationService.sendNotification(event.authorId(), "review-notification", event); + log.info("saved notification content: {}", event); + saveNotification(event); + } + + private void saveNotification(final NotificationCreateEvent event) { + notificationService.save( + event.authorId(), + event.authorName(), + event.content(), + event.actorName(), + event.actorId(), + event.targetType(), + event.targetId(), + event.notificationActionType(), + event.modifiedAt(), + event.projectId(), + event.projectStepId() + ); + } +} diff --git a/src/main/java/kr/mywork/domain/notification/model/Notification.java b/src/main/java/kr/mywork/domain/notification/model/Notification.java index 44e25a28..3906bfff 100644 --- a/src/main/java/kr/mywork/domain/notification/model/Notification.java +++ b/src/main/java/kr/mywork/domain/notification/model/Notification.java @@ -78,9 +78,4 @@ public Notification(UUID receiverMemberId, String receiverMemberName, public void markAsRead() { this.isRead = true; } - - // 타겟 삭제 처리 - public void markTargetAsDeleted() { - this.isTargetDeleted = true; - } -} \ No newline at end of file +} diff --git a/src/main/java/kr/mywork/domain/notification/service/RealTimeNotificationService.java b/src/main/java/kr/mywork/domain/notification/service/RealTimeNotificationService.java new file mode 100644 index 00000000..cee2e567 --- /dev/null +++ b/src/main/java/kr/mywork/domain/notification/service/RealTimeNotificationService.java @@ -0,0 +1,29 @@ +package kr.mywork.domain.notification.service; + +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import kr.mywork.common.sse.SseEmitters; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RealTimeNotificationService { + + private final SseEmitters sseEmitters; + + public SseEmitter addSseEmitter(final UUID clientId) { + return sseEmitters.add(clientId); + } + + public SseEmitter removeSseEmitter(final UUID clientId) { + return sseEmitters.remove(clientId); + } + + public boolean sendNotification(final UUID clientId, final String eventName, final T data) { + return sseEmitters.send(clientId, eventName, data); + } + +} diff --git a/src/main/java/kr/mywork/domain/post/listener/PostApprovalAlarmTxListener.java b/src/main/java/kr/mywork/domain/post/listener/PostApprovalNotificationTxListener.java similarity index 64% rename from src/main/java/kr/mywork/domain/post/listener/PostApprovalAlarmTxListener.java rename to src/main/java/kr/mywork/domain/post/listener/PostApprovalNotificationTxListener.java index 11cea612..ef03335e 100644 --- a/src/main/java/kr/mywork/domain/post/listener/PostApprovalAlarmTxListener.java +++ b/src/main/java/kr/mywork/domain/post/listener/PostApprovalNotificationTxListener.java @@ -8,20 +8,26 @@ import org.springframework.transaction.event.TransactionalEventListener; import kr.mywork.domain.notification.service.NotificationService; -import kr.mywork.domain.post.listener.event.PostApprovalAlarmEvent; +import kr.mywork.domain.notification.service.RealTimeNotificationService; +import kr.mywork.domain.post.listener.event.PostApprovalNotificationEvent; import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor -public class PostApprovalAlarmTxListener { +public class PostApprovalNotificationTxListener { + private final RealTimeNotificationService realTimeNotificationService; private final NotificationService notificationService; @Transactional(propagation = Propagation.REQUIRES_NEW) @Async("eventTaskExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handlePostApprovalAlarmEvent(final PostApprovalAlarmEvent event) { + public void handlePostApprovalAlarmEvent(final PostApprovalNotificationEvent event) { + realTimeNotificationService.sendNotification(event.authorId(), "notification-post-approval", event); + saveNotification(event); + } + private void saveNotification(final PostApprovalNotificationEvent event) { notificationService.save( event.authorId(), event.authorName(), event.postTitle(), event.memberName(), event.memberId(), event.targetType(), event.postId(), event.notificationActionType(), event.modifiedAt(), event.projectId(), diff --git a/src/main/java/kr/mywork/domain/post/listener/event/PostApprovalAlarmEvent.java b/src/main/java/kr/mywork/domain/post/listener/event/PostApprovalAlarmEvent.java deleted file mode 100644 index 161c28c8..00000000 --- a/src/main/java/kr/mywork/domain/post/listener/event/PostApprovalAlarmEvent.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.mywork.domain.post.listener.event; - -import java.time.LocalDateTime; -import java.util.UUID; - -import kr.mywork.domain.notification.model.NotificationActionType; -import kr.mywork.domain.notification.model.TargetType; - -public record PostApprovalAlarmEvent(UUID authorId, String authorName, String postTitle, UUID memberId, - String memberName, TargetType targetType, UUID postId, - NotificationActionType notificationActionType, LocalDateTime modifiedAt, - UUID projectId, UUID projectStepId) { -} diff --git a/src/main/java/kr/mywork/domain/post/listener/event/PostApprovalNotificationEvent.java b/src/main/java/kr/mywork/domain/post/listener/event/PostApprovalNotificationEvent.java new file mode 100644 index 00000000..39481a75 --- /dev/null +++ b/src/main/java/kr/mywork/domain/post/listener/event/PostApprovalNotificationEvent.java @@ -0,0 +1,13 @@ +package kr.mywork.domain.post.listener.event; + +import java.time.LocalDateTime; +import java.util.UUID; + +import kr.mywork.domain.notification.model.NotificationActionType; +import kr.mywork.domain.notification.model.TargetType; + +public record PostApprovalNotificationEvent(UUID authorId, String authorName, String postTitle, UUID memberId, + String memberName, TargetType targetType, UUID postId, + NotificationActionType notificationActionType, LocalDateTime modifiedAt, + UUID projectId, UUID projectStepId) { +} diff --git a/src/main/java/kr/mywork/domain/post/model/Post.java b/src/main/java/kr/mywork/domain/post/model/Post.java index c3ade4ed..10442645 100644 --- a/src/main/java/kr/mywork/domain/post/model/Post.java +++ b/src/main/java/kr/mywork/domain/post/model/Post.java @@ -1,6 +1,7 @@ package kr.mywork.domain.post.model; import java.time.LocalDateTime; +import java.util.Objects; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; @@ -97,4 +98,8 @@ public boolean isDeleted() { public boolean isApproved() { return this.approval.equals(APPROVED); } + + public boolean isAuthor(final UUID memberId) { + return Objects.equals(this.authorId, memberId); + } } diff --git a/src/main/java/kr/mywork/domain/post/service/PostService.java b/src/main/java/kr/mywork/domain/post/service/PostService.java index 806ab740..ad578b86 100644 --- a/src/main/java/kr/mywork/domain/post/service/PostService.java +++ b/src/main/java/kr/mywork/domain/post/service/PostService.java @@ -11,16 +11,16 @@ import org.springframework.transaction.annotation.Transactional; import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.DeleteEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogDeleteEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; import kr.mywork.domain.notification.model.NotificationActionType; import kr.mywork.domain.notification.model.TargetType; import kr.mywork.domain.post.errors.PostDeletedException; import kr.mywork.domain.post.errors.PostErrorType; import kr.mywork.domain.post.errors.PostIdNotFoundException; import kr.mywork.domain.post.errors.PostNotFoundException; -import kr.mywork.domain.post.listener.event.PostApprovalAlarmEvent; +import kr.mywork.domain.post.listener.event.PostApprovalNotificationEvent; import kr.mywork.domain.post.listener.event.PostAttachmentDeleteEvent; import kr.mywork.domain.post.listener.event.PostReviewsDeleteEvent; import kr.mywork.domain.post.model.Post; @@ -71,7 +71,21 @@ public PostApprovalResponse approvalPost(UUID postId, PostApprovalRequest postAp post.changeApproval(postApprovalRequest.getApprovalStatus()); - final PostApprovalAlarmEvent postApprovalAlarmEvent = new PostApprovalAlarmEvent( + final PostApprovalNotificationEvent postApprovalNotificationEvent = createPostApprovalAlarmEvent( + loginMemberDetail, post, projectStep); + + if (!post.getAuthorId().equals(loginMemberDetail.memberId())) { + eventPublisher.publishEvent(postApprovalNotificationEvent); + } + + eventPublisher.publishEvent(new ActivityModifyEvent(before, post, loginMemberDetail)); + + return new PostApprovalResponse(post.getId(), post.getApproval()); + } + + private PostApprovalNotificationEvent createPostApprovalAlarmEvent(final LoginMemberDetail loginMemberDetail, final Post post, + final ProjectStep projectStep) { + return new PostApprovalNotificationEvent( post.getAuthorId(), post.getAuthorName(), post.getTitle(), loginMemberDetail.memberId(), @@ -82,12 +96,6 @@ public PostApprovalResponse approvalPost(UUID postId, PostApprovalRequest postAp post.getModifiedAt(), projectStep.getProjectId(), projectStep.getId()); - - if (!post.getAuthorId().equals(loginMemberDetail.memberId())) - eventPublisher.publishEvent(postApprovalAlarmEvent); - eventPublisher.publishEvent(new ModifyEventObject(before, post, loginMemberDetail)); - - return new PostApprovalResponse(post.getId(), post.getApproval()); } @Transactional @@ -97,7 +105,7 @@ public UUID createPost(PostCreateRequest postCreateRequest, LoginMemberDetail lo final Post savedPost = postRepository.save(postCreateRequest); - eventPublisher.publishEvent(new CreateEventObject(savedPost, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogCreateEvent(savedPost, loginMemberDetail)); return savedPost.getId(); } @@ -110,7 +118,7 @@ public PostUpdateResponse updatePost(PostUpdateRequest postUpdateRequest, LoginM Post before = Post.copyOf(post); post.update(postUpdateRequest); - eventPublisher.publishEvent(new ModifyEventObject(before, post, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityModifyEvent(before, post, loginMemberDetail)); return PostUpdateResponse.from(post); } @@ -182,7 +190,7 @@ public UUID deletePost(UUID postId, LoginMemberDetail loginMemberDetail) { post.delete(); - eventPublisher.publishEvent(new DeleteEventObject(post, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogDeleteEvent(post, loginMemberDetail)); eventPublisher.publishEvent(new PostAttachmentDeleteEvent(postId)); eventPublisher.publishEvent(new PostReviewsDeleteEvent(postId)); diff --git a/src/main/java/kr/mywork/domain/post/service/ReviewService.java b/src/main/java/kr/mywork/domain/post/service/ReviewService.java index 822ca9b3..33b9a309 100644 --- a/src/main/java/kr/mywork/domain/post/service/ReviewService.java +++ b/src/main/java/kr/mywork/domain/post/service/ReviewService.java @@ -1,5 +1,6 @@ package kr.mywork.domain.post.service; +import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -12,12 +13,12 @@ import org.springframework.transaction.annotation.Transactional; import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.DeleteEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogDeleteEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; +import kr.mywork.domain.notification.listener.event.NotificationCreateEvent; import kr.mywork.domain.notification.model.NotificationActionType; import kr.mywork.domain.notification.model.TargetType; -import kr.mywork.domain.notification.service.NotificationService; import kr.mywork.domain.post.errors.PostErrorType; import kr.mywork.domain.post.errors.PostNotFoundException; import kr.mywork.domain.post.errors.review.ReviewErrorType; @@ -50,42 +51,50 @@ public class ReviewService { private final ReviewRepository reviewRepository; private final PostRepository postRepository; private final ProjectStepRepository projectStepRepository; - private final NotificationService notificationService; private final ApplicationEventPublisher eventPublisher; @Transactional - public ReviewCreateResponse save(final ReviewCreateRequest reviewCreateRequest, LoginMemberDetail loginMemberDetail) { + public ReviewCreateResponse save(final ReviewCreateRequest reviewCreateRequest, + LoginMemberDetail loginMemberDetail) { final Post post = postRepository.findById(reviewCreateRequest.postId()) .orElseThrow(() -> new PostNotFoundException(PostErrorType.POST_NOT_FOUND)); final Review review = reviewCreateRequest.toEntity(); final Review savedReview = reviewRepository.save(review); - eventPublisher.publishEvent(new CreateEventObject(savedReview, loginMemberDetail)); - final ProjectStep projectStep = projectStepRepository.findById(post.getProjectStepId()) .orElseThrow(() -> new ProjectStepNotFoundException(ProjectStepErrorType.PROJECT_STEP_NOT_FOUND)); - if (!post.getAuthorId().equals(loginMemberDetail.memberId())) { - notificationService.save( - post.getAuthorId(), - post.getAuthorName(), - post.getTitle(), - loginMemberDetail.memberName(), - loginMemberDetail.memberId(), - TargetType.POST, - post.getId(), - NotificationActionType.REVIEW, - savedReview.getCreatedAt(), - projectStep.getProjectId(), - projectStep.getId() - ); + final UUID memberId = loginMemberDetail.memberId(); + final NotificationCreateEvent notificationCreateEvent = createNotificationCreateEvent( + loginMemberDetail, post, projectStep); + + if (!post.isAuthor(memberId)) { + sendNotificationCreateEvent(notificationCreateEvent); } + sendActivityLogCreateEvent(loginMemberDetail, savedReview); return ReviewCreateResponse.fromEntity(savedReview); } - public ReviewModifyResponse modifyComment(final ReviewModifyRequest reviewModifyRequest, LoginMemberDetail loginMemberDetail) { + private NotificationCreateEvent createNotificationCreateEvent(final LoginMemberDetail loginMemberDetail, + final Post post, final ProjectStep projectStep) { + return new NotificationCreateEvent( + post.getAuthorId(), post.getAuthorName(), post.getTitle(), loginMemberDetail.memberName(), + loginMemberDetail.memberId(), TargetType.POST, post.getId(), NotificationActionType.REVIEW, + LocalDateTime.now(), projectStep.getProjectId(), projectStep.getId()); + } + + private void sendActivityLogCreateEvent(final LoginMemberDetail loginMemberDetail, final Review savedReview) { + eventPublisher.publishEvent(new ActivityLogCreateEvent(savedReview, loginMemberDetail)); + } + + private void sendNotificationCreateEvent(final NotificationCreateEvent notificationCreateEvent) { + eventPublisher.publishEvent(notificationCreateEvent); + } + + public ReviewModifyResponse modifyComment(final ReviewModifyRequest reviewModifyRequest, + LoginMemberDetail loginMemberDetail) { final Review review = reviewRepository.findById(reviewModifyRequest.reviewId()) .orElseThrow(() -> new ReviewNotFoundException(ReviewErrorType.REVIEW_NOT_FOUND)); @@ -93,19 +102,20 @@ public ReviewModifyResponse modifyComment(final ReviewModifyRequest reviewModify review.modifyComment(reviewModifyRequest.comment()); - eventPublisher.publishEvent(new ModifyEventObject(before, review, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityModifyEvent(before, review, loginMemberDetail)); return new ReviewModifyResponse(review.getId(), review.getComment()); } - public ReviewDeleteResponse deleteReview(final ReviewDeleteRequest reviewDeleteRequest, LoginMemberDetail loginMemberDetail) { + public ReviewDeleteResponse deleteReview(final ReviewDeleteRequest reviewDeleteRequest, + LoginMemberDetail loginMemberDetail) { // TODO 본인 작성 검증 내용 추가 final Review review = reviewRepository.findById(reviewDeleteRequest.reviewId()) .orElseThrow(() -> new ReviewNotFoundException(ReviewErrorType.REVIEW_NOT_FOUND)); review.delete(); - eventPublisher.publishEvent(new DeleteEventObject(review, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogDeleteEvent(review, loginMemberDetail)); return new ReviewDeleteResponse(review.getId()); } diff --git a/src/main/java/kr/mywork/domain/project/service/ProjectService.java b/src/main/java/kr/mywork/domain/project/service/ProjectService.java index 280030ab..37dfae7f 100644 --- a/src/main/java/kr/mywork/domain/project/service/ProjectService.java +++ b/src/main/java/kr/mywork/domain/project/service/ProjectService.java @@ -12,9 +12,9 @@ import org.springframework.transaction.annotation.Transactional; import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.DeleteEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogDeleteEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; import kr.mywork.domain.company.errors.CompanyErrorType; import kr.mywork.domain.company.errors.CompanyNotFoundException; import kr.mywork.domain.company.model.Company; @@ -72,7 +72,7 @@ public UUID createProject(ProjectCreateRequest request, LoginMemberDetail loginM projectAssignRepository.save( new ProjectAssign(savedProject.getId(), request.devCompanyId(), request.clientCompanyId())); - eventPublisher.publishEvent(new CreateEventObject(savedProject, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogCreateEvent(savedProject, loginMemberDetail)); return savedProject.getId(); } @@ -84,7 +84,7 @@ public UUID deleteProject(UUID projectId, LoginMemberDetail loginMemberDetail) { project.setDeleted(true); - eventPublisher.publishEvent(new DeleteEventObject(project, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogDeleteEvent(project, loginMemberDetail)); return project.getId(); } @@ -98,7 +98,7 @@ public ProjectUpdateResponse updateProject(UUID projectId, ProjectUpdateRequest project.updateFrom(request); - eventPublisher.publishEvent(new ModifyEventObject(before, project, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityModifyEvent(before, project, loginMemberDetail)); return ProjectUpdateResponse.from(project); } diff --git a/src/main/java/kr/mywork/domain/project_checklist/listener/CheckListNotificationTxListener.java b/src/main/java/kr/mywork/domain/project_checklist/listener/CheckListNotificationTxListener.java new file mode 100644 index 00000000..2f6a4487 --- /dev/null +++ b/src/main/java/kr/mywork/domain/project_checklist/listener/CheckListNotificationTxListener.java @@ -0,0 +1,44 @@ +package kr.mywork.domain.project_checklist.listener; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import kr.mywork.domain.notification.service.NotificationService; +import kr.mywork.domain.notification.service.RealTimeNotificationService; +import kr.mywork.domain.project_checklist.listener.event.CheckListApprovalNotificationEvent; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CheckListNotificationTxListener { + + private final RealTimeNotificationService realTimeNotificationService; + private final NotificationService notificationService; + + @Async("eventTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleCheckListCreatedHistory(final CheckListApprovalNotificationEvent event) { + realTimeNotificationService.sendNotification(event.authorId(), "notification-checklist-approval", event); + saveNotification(event); + } + + private void saveNotification(final CheckListApprovalNotificationEvent event) { + notificationService.save( + event.authorId(), + event.authorName(), + event.checkListTitle(), + event.authorName(), + event.memberId(), + event.targetType(), + event.checkListId(), + event.notificationActionType(), + event.modifiedAt(), + event.projectId(), + event.projectStepId()); + } +} diff --git a/src/main/java/kr/mywork/domain/project_checklist/listener/event/CheckListApprovalNotificationEvent.java b/src/main/java/kr/mywork/domain/project_checklist/listener/event/CheckListApprovalNotificationEvent.java new file mode 100644 index 00000000..c452430b --- /dev/null +++ b/src/main/java/kr/mywork/domain/project_checklist/listener/event/CheckListApprovalNotificationEvent.java @@ -0,0 +1,14 @@ +package kr.mywork.domain.project_checklist.listener.event; + +import java.time.LocalDateTime; +import java.util.UUID; + +import kr.mywork.domain.notification.model.NotificationActionType; +import kr.mywork.domain.notification.model.TargetType; + +public record CheckListApprovalNotificationEvent(UUID authorId, String authorName, String checkListTitle, + String memberName, UUID memberId, TargetType targetType, + UUID checkListId, NotificationActionType notificationActionType, + LocalDateTime modifiedAt, UUID projectId, UUID projectStepId) { + +} diff --git a/src/main/java/kr/mywork/domain/project_checklist/service/ProjectCheckListService.java b/src/main/java/kr/mywork/domain/project_checklist/service/ProjectCheckListService.java index 6c959b5f..e7318e1f 100644 --- a/src/main/java/kr/mywork/domain/project_checklist/service/ProjectCheckListService.java +++ b/src/main/java/kr/mywork/domain/project_checklist/service/ProjectCheckListService.java @@ -11,9 +11,9 @@ import org.springframework.transaction.annotation.Transactional; import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.DeleteEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogDeleteEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; import kr.mywork.domain.notification.model.NotificationActionType; import kr.mywork.domain.notification.model.NotificationTitle; import kr.mywork.domain.notification.model.TargetType; @@ -24,6 +24,7 @@ import kr.mywork.domain.project.repository.ProjectRepository; import kr.mywork.domain.project_checklist.errors.ProjectCheckListErrorType; import kr.mywork.domain.project_checklist.errors.ProjectCheckListNotFoundException; +import kr.mywork.domain.project_checklist.listener.event.CheckListApprovalNotificationEvent; import kr.mywork.domain.project_checklist.listener.event.CheckListApprovalUpdateEvent; import kr.mywork.domain.project_checklist.listener.event.CheckListHistoryCreationEvent; import kr.mywork.domain.project_checklist.model.ProjectCheckList; @@ -61,7 +62,7 @@ public ProjectCheckListCreateResponse createProjectCheckList( ProjectCheckList projectCheckList = projectCheckListRepository.save(projectCheckListRequest); - eventPublisher.publishEvent(new CreateEventObject(projectCheckList, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogCreateEvent(projectCheckList, loginMemberDetail)); eventPublisher.publishEvent(new CheckListHistoryCreationEvent( projectCheckList.getId(), loginMemberDetail.companyName(), @@ -92,7 +93,7 @@ public ProjectCheckListUpdateResponse updateProjectCheckList( projectCheckList.update(projectCheckListUpdateRequest); - eventPublisher.publishEvent(new ModifyEventObject(before, projectCheckList, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityModifyEvent(before, projectCheckList, loginMemberDetail)); return ProjectCheckListUpdateResponse.from(projectCheckList); } @@ -105,7 +106,7 @@ public UUID deleteProjectCheckList(UUID checkListId, LoginMemberDetail loginMemb projectCheckList.softDelete(); - eventPublisher.publishEvent(new DeleteEventObject(projectCheckList, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogDeleteEvent(projectCheckList, loginMemberDetail)); return projectCheckList.getId(); } @@ -121,7 +122,7 @@ public ProjectCheckListApprovalResponse approvalProjectCheckList( projectCheckList.changeApproval(projectCheckListApprovalRequest); - eventPublisher.publishEvent(new ModifyEventObject(before, projectCheckList, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityModifyEvent(before, projectCheckList, loginMemberDetail)); applicationEventPublisher.publishEvent( new CheckListApprovalUpdateEvent( projectCheckListApprovalRequest.id(), @@ -131,26 +132,32 @@ public ProjectCheckListApprovalResponse approvalProjectCheckList( loginMemberDetail.memberName())); ProjectStep projectStep = projectStepRepository.findById(projectCheckList.getProjectStepId()) - .orElseThrow(() -> new ProjectStepNotFoundException(ProjectStepErrorType.PROJECT_STEP_NOT_FOUND)); - - notificationService.save( - projectCheckList.getAuthorId(), - projectCheckList.getAuthorName(), - projectCheckList.getTitle(), - loginMemberDetail.memberName(), - loginMemberDetail.memberId(), - TargetType.PROJECT_CHECK_LIST, - projectCheckList.getId(), - determineProjectCheckListActionType(projectCheckList.getApproval()), - projectCheckList.getModifiedAt(), - projectStep.getProjectId(), - projectStep.getId() - ); + .orElseThrow(() -> new ProjectStepNotFoundException(ProjectStepErrorType.PROJECT_STEP_NOT_FOUND)); + final CheckListApprovalNotificationEvent checkListApprovalNotificationEvent = + createCheckListApprovalNotificationEvent(loginMemberDetail, projectCheckList, projectStep); + applicationEventPublisher.publishEvent(checkListApprovalNotificationEvent); return ProjectCheckListApprovalResponse.from(projectCheckList); } + private CheckListApprovalNotificationEvent createCheckListApprovalNotificationEvent(final LoginMemberDetail loginMemberDetail, + final ProjectCheckList projectCheckList, final ProjectStep projectStep) { + return new CheckListApprovalNotificationEvent( + projectCheckList.getAuthorId(), + projectCheckList.getAuthorName(), + determineProjectCheckListTitle(projectCheckList.getApproval()), + loginMemberDetail.memberName(), + loginMemberDetail.memberId(), + TargetType.PROJECT_CHECK_LIST, + projectCheckList.getId(), + determineProjectCheckListActionType(projectCheckList.getApproval()), + projectCheckList.getModifiedAt(), + projectStep.getProjectId(), + projectStep.getId() + ); + } + private String determineProjectCheckListTitle(final String approvalStatus) { if (approvalStatus.equals("APPROVED")) return NotificationTitle.PROJECT_CHECK_LIST_APPROVED.getTitle(); diff --git a/src/main/java/kr/mywork/domain/project_member/service/ProjectMemberService.java b/src/main/java/kr/mywork/domain/project_member/service/ProjectMemberService.java index 2bf806ee..9f6b4374 100644 --- a/src/main/java/kr/mywork/domain/project_member/service/ProjectMemberService.java +++ b/src/main/java/kr/mywork/domain/project_member/service/ProjectMemberService.java @@ -2,8 +2,8 @@ import jakarta.transaction.Transactional; import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.DeleteEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogDeleteEvent; import kr.mywork.domain.project.model.ProjectMember; import kr.mywork.domain.project_member.error.ProjectMemberErrorType; import kr.mywork.domain.project_member.error.ProjectMemberNotFoundException; @@ -39,7 +39,7 @@ public UUID addMemberToCompany(UUID projectId, UUID memberId, LoginMemberDetail private UUID createProjectMember(final UUID projectId, final UUID memberId, LoginMemberDetail loginMemberDetail) { final ProjectMember savedMember = projectMemberRepository.save(new ProjectMember(projectId, memberId)); - eventPublisher.publishEvent(new CreateEventObject(savedMember, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogCreateEvent(savedMember, loginMemberDetail)); return savedMember.getMemberId(); } @@ -50,7 +50,7 @@ private UUID restoreProjectMember(final UUID projectId, final UUID memberId, Log projectMember.restore(); - eventPublisher.publishEvent(new CreateEventObject(projectMember, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogCreateEvent(projectMember, loginMemberDetail)); return projectMember.getId(); } @@ -68,7 +68,7 @@ public UUID deleteMemberById(UUID memberId, UUID projectId, LoginMemberDetail lo projectMember.delete(); - eventPublisher.publishEvent(new DeleteEventObject(projectMember, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogDeleteEvent(projectMember, loginMemberDetail)); return projectMember.getId(); } diff --git a/src/main/java/kr/mywork/domain/project_step/serivce/ProjectStepService.java b/src/main/java/kr/mywork/domain/project_step/serivce/ProjectStepService.java index 1330c26b..5a1f5b08 100644 --- a/src/main/java/kr/mywork/domain/project_step/serivce/ProjectStepService.java +++ b/src/main/java/kr/mywork/domain/project_step/serivce/ProjectStepService.java @@ -11,8 +11,8 @@ import org.springframework.transaction.annotation.Transactional; import kr.mywork.common.auth.components.dto.LoginMemberDetail; -import kr.mywork.domain.activityLog.listener.eventObject.CreateEventObject; -import kr.mywork.domain.activityLog.listener.eventObject.ModifyEventObject; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityLogCreateEvent; +import kr.mywork.domain.activityLog.listener.eventObject.ActivityModifyEvent; import kr.mywork.domain.project_step.model.ProjectStep; import kr.mywork.domain.project_step.repository.ProjectStepRepository; import kr.mywork.domain.project_step.serivce.dto.request.ProjectStepCreateRequest; @@ -40,7 +40,7 @@ public Integer saveAll( final List savedProjectSteps = projectStepRepository.saveAll(projectSteps); projectSteps.forEach(projectStep -> { - eventPublisher.publishEvent(new CreateEventObject(projectStep, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityLogCreateEvent(projectStep, loginMemberDetail)); }); return savedProjectSteps.size(); @@ -63,7 +63,7 @@ public List updateProjectSteps( ProjectStep before = ProjectStep.copyOf(projectStep); projectStep.update(projectStepUpdateRequest.title(), projectStepUpdateRequest.orderNum()); - eventPublisher.publishEvent(new ModifyEventObject(before, projectStep, loginMemberDetail)); + eventPublisher.publishEvent(new ActivityModifyEvent(before, projectStep, loginMemberDetail)); }); return projectSteps.stream().map(ProjectStepUpdateResponse::fromEntity).toList(); diff --git a/src/main/java/kr/mywork/interfaces/notification/controller/RealNotificationController.java b/src/main/java/kr/mywork/interfaces/notification/controller/RealNotificationController.java new file mode 100644 index 00000000..c2b56655 --- /dev/null +++ b/src/main/java/kr/mywork/interfaces/notification/controller/RealNotificationController.java @@ -0,0 +1,32 @@ +package kr.mywork.interfaces.notification.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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; + +import kr.mywork.common.auth.components.annotation.LoginMember; +import kr.mywork.common.auth.components.dto.LoginMemberDetail; +import kr.mywork.domain.notification.service.NotificationService; +import kr.mywork.domain.notification.service.RealTimeNotificationService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class RealNotificationController { + + private final RealTimeNotificationService realTimeNotificationService; + private final NotificationService notificationService; + + @GetMapping( value = "/real-notifications/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity connectForRealTimeNotification(@LoginMember LoginMemberDetail loginMemberDetail) { + final SseEmitter sseEmitter = realTimeNotificationService.addSseEmitter(loginMemberDetail.memberId()); + long count = notificationService.countUnreadNotifications(loginMemberDetail.memberId()); + realTimeNotificationService.sendNotification(loginMemberDetail.memberId(), "notification-unread-count", count); + return ResponseEntity.ok(sseEmitter); + } + +} diff --git a/src/test/java/kr/mywork/domain/post/service/ReviewServiceTest.java b/src/test/java/kr/mywork/domain/post/service/ReviewServiceTest.java index b6e6b373..20429473 100644 --- a/src/test/java/kr/mywork/domain/post/service/ReviewServiceTest.java +++ b/src/test/java/kr/mywork/domain/post/service/ReviewServiceTest.java @@ -16,8 +16,6 @@ import org.mockito.Mockito; import org.springframework.context.ApplicationEventPublisher; -import kr.mywork.domain.notification.repository.NotificationRepository; -import kr.mywork.domain.notification.service.NotificationService; import kr.mywork.domain.post.repository.PostRepository; import kr.mywork.domain.post.repository.ReviewRepository; import kr.mywork.domain.post.service.dto.ReviewSelectResponse; @@ -29,7 +27,6 @@ class ReviewServiceTest { private ReviewRepository reviewRepository; private PostRepository postRepository; private ProjectStepRepository projectStepRepository; - private NotificationService notificationService; private ApplicationEventPublisher eventPublisher; @BeforeEach @@ -37,14 +34,8 @@ void setUp() { this.reviewRepository = Mockito.mock(ReviewRepository.class); this.postRepository = Mockito.mock(PostRepository.class); this.projectStepRepository = Mockito.mock(ProjectStepRepository.class); - this.notificationService = Mockito.mock(NotificationService.class); - this.reviewService = new ReviewService( - reviewRepository, - postRepository, - projectStepRepository, - notificationService, - eventPublisher - ); + this.eventPublisher = Mockito.mock(ApplicationEventPublisher.class); + this.reviewService = new ReviewService(reviewRepository, postRepository, projectStepRepository, eventPublisher); } @Test