diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventController.java b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventController.java index 87a92af1..b7364ea7 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventController.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventController.java @@ -2,8 +2,12 @@ import endolphin.backend.domain.personal_event.dto.PersonalEventRequest; import endolphin.backend.domain.personal_event.dto.PersonalEventResponse; +import endolphin.backend.domain.personal_event.dto.SyncResponse; +import endolphin.backend.domain.user.UserService; +import endolphin.backend.domain.user.entity.User; import endolphin.backend.global.dto.ListResponse; import endolphin.backend.global.error.ErrorResponse; +import endolphin.backend.global.util.DeferredResultManager; import io.swagger.v3.oas.annotations.Operation; import endolphin.backend.global.util.URIUtil; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -16,6 +20,7 @@ import jakarta.validation.constraints.NotNull; import java.net.URI; import java.time.LocalDate; + import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -27,6 +32,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; @Tag(name = "Personal Event", description = "개인 일정 관리 API") @RestController @@ -35,6 +41,8 @@ public class PersonalEventController { private final PersonalEventService personalEventService; + private final DeferredResultManager deferredResultManager; + private final UserService userService; @Operation(summary = "개인 일정 조회", description = "사용자의 개인 일정을 주 단위로 조회합니다.") @ApiResponses(value = { @@ -53,8 +61,9 @@ public class PersonalEventController { public ResponseEntity> getPersonalEvents( @Valid @NotNull @RequestParam LocalDate startDate, @Valid @NotNull @RequestParam LocalDate endDate) { - ListResponse response = personalEventService.listPersonalEvents( - startDate, endDate); + ListResponse response = + personalEventService.listPersonalEvents(startDate, endDate); + return ResponseEntity.ok(response); } @@ -118,4 +127,19 @@ public ResponseEntity deletePersonalEvent( personalEventService.deleteWithRequest(personalEventId, syncWithGoogleCalendar); return ResponseEntity.noContent().build(); } + + @Operation(summary = "개인 일정 동기화", description = "개인 일정을 실시간으로 동기화합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "일정 동기화 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/sync") + public DeferredResult> poll() { + User user = userService.getCurrentUser(); + + return deferredResultManager.create(user); + } } diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java index c41860c2..965da432 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java @@ -18,19 +18,18 @@ import endolphin.backend.global.error.exception.ApiException; import endolphin.backend.global.error.exception.ErrorCode; import endolphin.backend.global.google.dto.GoogleEvent; -import endolphin.backend.global.google.enums.GoogleEventStatus; import endolphin.backend.domain.personal_event.event.InsertPersonalEvent; import endolphin.backend.global.util.IdGenerator; import endolphin.backend.global.util.Validator; + import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -52,15 +51,15 @@ public class PersonalEventService { private final CalendarService calendarService; @Transactional(readOnly = true) - public ListResponse listPersonalEvents(LocalDate startDate, - LocalDate endDate) { + public ListResponse listPersonalEvents( + LocalDate startDate, LocalDate endDate) { User user = userService.getCurrentUser(); Validator.validateDateTimeRange(startDate, endDate); List personalEventResponseList = personalEventRepository.findFilteredPersonalEvents( - user, startDate, endDate) - .stream().map(PersonalEventResponse::fromEntity).toList(); + user, startDate, endDate).stream().map(PersonalEventResponse::fromEntity).toList(); + return new ListResponse<>(personalEventResponseList); } @@ -220,24 +219,19 @@ public void preprocessPersonalEvents(User user, Discussion discussion) { personalEventPreprocessor.preprocess(personalEvents, discussion, user); } - public Set syncWithGoogleEvents(List googleEvents, User user, + public void syncWithGoogleEvents(List googleEvents, User user, String googleCalendarId) { List discussions = discussionParticipantService.getDiscussionsByUserId( user.getId()); - Set changedDates = new HashSet<>(); for (GoogleEvent googleEvent : googleEvents) { log.info("Processing Google event: {}", googleEvent); - if (googleEvent.status().equals(GoogleEventStatus.CONFIRMED)) { - upsertPersonalEventByGoogleEvent(googleEvent, discussions, user, googleCalendarId, - changedDates); - changedDates.add(googleEvent.startDateTime().toLocalDate()); - changedDates.add(googleEvent.endDateTime().toLocalDate()); - } else if (googleEvent.status().equals(GoogleEventStatus.CANCELLED)) { - deletePersonalEventByGoogleEvent(googleEvent, discussions, user, googleCalendarId, - changedDates); + switch (googleEvent.status()) { + case CONFIRMED -> upsertPersonalEventByGoogleEvent( + googleEvent, discussions, user, googleCalendarId); + case CANCELLED -> deletePersonalEventByGoogleEvent( + googleEvent, discussions, user, googleCalendarId); } } - return changedDates; } private void validatePersonalEventUser(PersonalEvent personalEvent, User user) { @@ -246,15 +240,13 @@ private void validatePersonalEventUser(PersonalEvent personalEvent, User user) { } } - private void upsertPersonalEventByGoogleEvent(GoogleEvent googleEvent, - List discussions, User user, String googleCalendarId, - Set changedDates) { + private void upsertPersonalEventByGoogleEvent( + GoogleEvent googleEvent, List discussions, User user, String googleCalendarId) { log.info("Upserting personal event by Google event: {}", googleEvent); - personalEventRepository.findByGoogleEventIdAndCalendarId(googleEvent.eventId(), - googleCalendarId) + + personalEventRepository + .findByGoogleEventIdAndCalendarId(googleEvent.eventId(), googleCalendarId) .ifPresentOrElse(personalEvent -> { - changedDates.add(personalEvent.getStartTime().toLocalDate()); - changedDates.add(personalEvent.getEndTime().toLocalDate()); updatePersonalEvent( PersonalEventRequest.of(googleEvent, personalEvent.getIsAdjustable()), personalEvent, user, discussions); @@ -272,14 +264,11 @@ private void upsertPersonalEventByGoogleEvent(GoogleEvent googleEvent, } private void deletePersonalEventByGoogleEvent(GoogleEvent googleEvent, - List discussions, User user, String googleCalendarId, - Set changedDates) { + List discussions, User user, String googleCalendarId) { log.info("Deleting personal event by Google event: {}", googleEvent); - personalEventRepository.findByGoogleEventIdAndCalendarId(googleEvent.eventId(), - googleCalendarId) + personalEventRepository + .findByGoogleEventIdAndCalendarId(googleEvent.eventId(), googleCalendarId) .ifPresent(personalEvent -> { - changedDates.add(personalEvent.getStartTime().toLocalDate()); - changedDates.add(personalEvent.getEndTime().toLocalDate()); deletePersonalEvent(personalEvent, user, discussions); }); } @@ -343,5 +332,4 @@ public void deletePersonalEventsByDiscussionId(Long discussionId) { personalEventRepository.deleteAll(events); } } - } diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncPersonalEvent.java b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncPersonalEvent.java new file mode 100644 index 00000000..382b9b9e --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncPersonalEvent.java @@ -0,0 +1,17 @@ +package endolphin.backend.domain.personal_event.dto; + +import endolphin.backend.global.google.dto.GoogleEvent; +import java.time.LocalDateTime; + +public record SyncPersonalEvent( + String googleEventId, + String title, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + String status +) { + public static SyncPersonalEvent from(GoogleEvent event) { + return new SyncPersonalEvent(event.eventId(), event.summary(), event.startDateTime(), + event.endDateTime(), event.status().getValue()); + } +} diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncResponse.java b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncResponse.java new file mode 100644 index 00000000..d605e5a8 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/SyncResponse.java @@ -0,0 +1,20 @@ +package endolphin.backend.domain.personal_event.dto; + +import java.util.List; + +public record SyncResponse( + List events, + String type +) { + public static SyncResponse timeout() { + return new SyncResponse(null, "timeout"); + } + + public static SyncResponse sync(List events) { + return new SyncResponse(events, "sync"); + } + + public static SyncResponse replaced() { + return new SyncResponse(null, "replaced"); + } +} diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/event/handler/PersonalEventHandler.java b/backend/src/main/java/endolphin/backend/domain/personal_event/event/handler/PersonalEventHandler.java index 4e36c80d..2e44566d 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/event/handler/PersonalEventHandler.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/event/handler/PersonalEventHandler.java @@ -1,13 +1,13 @@ package endolphin.backend.domain.personal_event.event.handler; import endolphin.backend.domain.personal_event.PersonalEventService; +import endolphin.backend.domain.personal_event.dto.SyncPersonalEvent; +import endolphin.backend.domain.personal_event.dto.SyncResponse; import endolphin.backend.domain.user.entity.User; import endolphin.backend.global.google.dto.GoogleEvent; import endolphin.backend.global.google.event.GoogleEventChanged; -import endolphin.backend.global.sse.SseEmitters; -import java.time.LocalDate; +import endolphin.backend.global.util.DeferredResultManager; import java.util.List; -import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; @@ -19,7 +19,7 @@ public class PersonalEventHandler { private final PersonalEventService personalEventService; - private final SseEmitters sseEmitters; + private final DeferredResultManager deferredResultManager; @EventListener(classes = {GoogleEventChanged.class}) public void sync(GoogleEventChanged event) { @@ -29,10 +29,13 @@ public void sync(GoogleEventChanged event) { User user = event.user(); log.info("Syncing personal events for user {}", user.getId()); String googleCalendarId = event.googleCalendarId(); - Set changedDates = personalEventService.syncWithGoogleEvents(events, user, - googleCalendarId); + personalEventService.syncWithGoogleEvents(events, user, googleCalendarId); - sseEmitters.sendToUser(user.getId(), changedDates); + if (deferredResultManager.hasActiveConnection(user)) { + List syncPersonalEvents = + events.stream().map(SyncPersonalEvent::from).toList(); + deferredResultManager.setResult(user, SyncResponse.sync(syncPersonalEvents)); + } } catch (Exception e) { log.error("Failed to sync personal events", e); } diff --git a/backend/src/main/java/endolphin/backend/global/sse/CalendarEventController.java b/backend/src/main/java/endolphin/backend/global/sse/CalendarEventController.java deleted file mode 100644 index 74210429..00000000 --- a/backend/src/main/java/endolphin/backend/global/sse/CalendarEventController.java +++ /dev/null @@ -1,28 +0,0 @@ -package endolphin.backend.global.sse; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.time.LocalDate; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -@Tag(name = "SSE", description = "Server-Sent Events") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/sse/events") -public class CalendarEventController { - - private final SseEmitters emitters; - - @Operation(summary = "구독", description = "사용자의 캘린더 이벤트를 구독합니다.") - @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter subscribe(@RequestParam("userId") Long userId) { - return emitters.add(userId); - } -} \ No newline at end of file diff --git a/backend/src/main/java/endolphin/backend/global/sse/SseEmitters.java b/backend/src/main/java/endolphin/backend/global/sse/SseEmitters.java deleted file mode 100644 index a3dd06d1..00000000 --- a/backend/src/main/java/endolphin/backend/global/sse/SseEmitters.java +++ /dev/null @@ -1,49 +0,0 @@ -package endolphin.backend.global.sse; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.io.IOException; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Component -@Slf4j -public class SseEmitters { - - private final Map emitters = new ConcurrentHashMap<>(); - private static final Long TIMEOUT = 1000L * 60 * 30; - - public SseEmitter add(Long userId) { - SseEmitter emitter = new SseEmitter(TIMEOUT); - log.info("User {} connected", userId); - - emitter.onCompletion(() -> emitters.remove(userId)); - emitter.onTimeout(() -> emitters.remove(userId)); - - emitters.put(userId, emitter); - - try { - emitter.send(SseEmitter.event().comment("connected")); - log.info("Dummy Data sent to User {}", userId); - } catch (IOException e) { - emitters.remove(userId); - } - - return emitter; - } - - public void sendToUser(Long userId, Object data) { - SseEmitter emitter = emitters.get(userId); - if (emitter != null) { - try { - log.info("Data {} sent to User {}", data, userId); - emitter.send(SseEmitter.event().data(data)); - } catch (IOException e) { - emitters.remove(userId); - } - } - } -} - diff --git a/backend/src/main/java/endolphin/backend/global/util/DeferredResultManager.java b/backend/src/main/java/endolphin/backend/global/util/DeferredResultManager.java new file mode 100644 index 00000000..3daf9fa3 --- /dev/null +++ b/backend/src/main/java/endolphin/backend/global/util/DeferredResultManager.java @@ -0,0 +1,51 @@ +package endolphin.backend.global.util; + +import endolphin.backend.domain.personal_event.dto.SyncResponse; +import endolphin.backend.domain.user.entity.User; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.async.DeferredResult; + +@Service +public class DeferredResultManager { + + private final Map>> results = + new ConcurrentHashMap<>(); + private final static Long TIMEOUT = 60000L; + + public DeferredResult> create(User user) { + Long userId = user.getId(); + if (results.containsKey(userId)) { + DeferredResult> result = results.get(userId); + result.setResult(ResponseEntity.ok(SyncResponse.replaced())); + results.remove(userId); + } + DeferredResult> deferredResult = + new DeferredResult<>(TIMEOUT, ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT) + .body(SyncResponse.timeout())); + deferredResult.onCompletion(() -> { + results.remove(userId); + }); + deferredResult.onTimeout(() -> { + results.remove(userId); + }); + results.put(userId, deferredResult); + return deferredResult; + } + + public void setResult(User user, SyncResponse response) { + DeferredResult> deferredResult = + results.get(user.getId()); + if (deferredResult != null && !deferredResult.isSetOrExpired()) { + deferredResult.setResult(ResponseEntity.ok(response)); + } + } + + public boolean hasActiveConnection(User user) { + DeferredResult> result = results.get(user.getId()); + return result != null && !result.isSetOrExpired(); + } +} diff --git a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventServiceTest.java b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventServiceTest.java index ad918f64..3f1e9034 100644 --- a/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventServiceTest.java +++ b/backend/src/test/java/endolphin/backend/domain/personal_event/PersonalEventServiceTest.java @@ -233,14 +233,14 @@ public void testUpdateWithRequestByGoogleSync_Success() { PersonalEvent existingEvent = createWithRequest("new Title"); given(existingEvent.getStartTime()).willReturn(LocalDateTime.of(2024, 3, 10, 10, 0)); - given(existingEvent.getEndTime()).willReturn(LocalDateTime.of(2024, 3, 10, 12, 0)); +// given(existingEvent.getEndTime()).willReturn(LocalDateTime.of(2024, 3, 10, 12, 0)); PersonalEvent oldExistingEvent = createWithRequest("Old Title"); given(existingEvent.copy()).willReturn(oldExistingEvent); PersonalEvent existingEvent2 = createWithRequest("Old Title2"); - given(existingEvent2.getStartTime()).willReturn(LocalDateTime.of(2024, 5, 10, 7, 0)); - given(existingEvent2.getEndTime()).willReturn(LocalDateTime.of(2024, 5, 10, 12, 0)); +// given(existingEvent2.getStartTime()).willReturn(LocalDateTime.of(2024, 5, 10, 7, 0)); +// given(existingEvent2.getEndTime()).willReturn(LocalDateTime.of(2024, 5, 10, 12, 0)); given(personalEventRepository.findByGoogleEventIdAndCalendarId(eq(updatedGoogleEvent.eventId()), eq(googleCalendarId))) .willReturn(Optional.of(existingEvent));