From d87182fb47684e15ecec96747f55ad51f32c88cd Mon Sep 17 00:00:00 2001 From: rabitis99 Date: Tue, 9 Sep 2025 17:59:51 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=EC=B6=9C=EC=84=9D=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../volunteer/dto/Post/CreatePostRequest.java | 2 + .../volunteer/dto/Post/UpdatePostRequest.java | 3 ++ .../Post/common/PostAttendancePolicyDto.java | 15 +----- .../AttendancePolicyConstraintValidator.java | 52 +++++++++++++++++++ .../dto/validator/ValidAttendancePolicy.java | 19 +++++++ 5 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/validator/AttendancePolicyConstraintValidator.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/validator/ValidAttendancePolicy.java diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/CreatePostRequest.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/CreatePostRequest.java index 28efd92..727d035 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/CreatePostRequest.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/CreatePostRequest.java @@ -4,6 +4,7 @@ import com.example.emergencyassistb4b4.domain.volunteer.domain.Post; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common.PostAttendancePolicyDto; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common.PostLocationDto; +import com.example.emergencyassistb4b4.domain.volunteer.dto.validator.ValidAttendancePolicy; import com.example.emergencyassistb4b4.domain.volunteer.enums.PostCategory; import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; import jakarta.persistence.EnumType; @@ -21,6 +22,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@ValidAttendancePolicy public class CreatePostRequest { @NotBlank(message = "제목은 필수입니다.") diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UpdatePostRequest.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UpdatePostRequest.java index 2a19b1c..1ebc68a 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UpdatePostRequest.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UpdatePostRequest.java @@ -2,6 +2,7 @@ import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common.PostAttendancePolicyDto; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common.PostLocationDto; +import com.example.emergencyassistb4b4.domain.volunteer.dto.validator.ValidAttendancePolicy; import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -17,6 +18,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@ValidAttendancePolicy public class UpdatePostRequest { @NotBlank(message = "제목은 필수입니다.") @@ -47,6 +49,7 @@ public class UpdatePostRequest { private PostLocationDto location; @Valid + @NotNull(message = "출석 정책은 필수입니다.") private PostAttendancePolicyDto attendancePolicy; } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/common/PostAttendancePolicyDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/common/PostAttendancePolicyDto.java index b3bbdeb..3a16434 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/common/PostAttendancePolicyDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/common/PostAttendancePolicyDto.java @@ -1,6 +1,7 @@ package com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common; import com.example.emergencyassistb4b4.domain.volunteer.domain.AttendancePolicy; +import com.example.emergencyassistb4b4.domain.volunteer.dto.validator.ValidAttendancePolicy; import com.example.emergencyassistb4b4.global.exception.ApiException; import com.example.emergencyassistb4b4.global.status.ErrorStatus; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -17,6 +18,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@ValidAttendancePolicy public class PostAttendancePolicyDto implements AttendancePolicyProvider { @NotNull(message = "출석 시작 시간은 필수입니다.") @@ -29,13 +31,11 @@ public class PostAttendancePolicyDto implements AttendancePolicyProvider { private int allowedRadiusM; @Override - @JsonIgnore public AttendancePolicy getAttendancePolicy() { return toEntity(); } public AttendancePolicy toEntity() { - checkTime(checkinStart, checkinEnd); return AttendancePolicy.builder() .checkinStart(checkinStart) .checkinEnd(checkinEnd) @@ -52,15 +52,4 @@ public static PostAttendancePolicyDto from(AttendancePolicy policy) { .build(); } - private static void checkTime(LocalDateTime checkinStart, LocalDateTime checkinEnd) { - LocalDateTime now = LocalDateTime.now(); - - if (checkinStart.isAfter(checkinEnd)) { - throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); - } - - if (checkinEnd.isBefore(now)) { - throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); - } - } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/validator/AttendancePolicyConstraintValidator.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/validator/AttendancePolicyConstraintValidator.java new file mode 100644 index 0000000..5253b51 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/validator/AttendancePolicyConstraintValidator.java @@ -0,0 +1,52 @@ +package com.example.emergencyassistb4b4.domain.volunteer.dto.validator; + +import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.CreatePostRequest; +import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.UpdatePostRequest; +import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common.PostAttendancePolicyDto; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.time.LocalDateTime; + +public class AttendancePolicyConstraintValidator + implements ConstraintValidator { + + @Override + public boolean isValid(Object dto, ConstraintValidatorContext context) { + PostAttendancePolicyDto policy = extractPolicy(dto); + if (policy == null) return true; + + LocalDateTime now = LocalDateTime.now(); + boolean valid = true; + + if (policy.getCheckinStart() != null && policy.getCheckinEnd() != null) { + + if (policy.getCheckinStart().isAfter(policy.getCheckinEnd())) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate("출석 시작 시간이 종료 시간보다 늦습니다.") + .addPropertyNode("attendancePolicy.checkinStart") + .addConstraintViolation(); + valid = false; + } + + if (policy.getCheckinEnd().isBefore(now)) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate("출석 종료 시간이 이미 지났습니다.") + .addPropertyNode("attendancePolicy.checkinEnd") + .addConstraintViolation(); + valid = false; + } + } + + return valid; + } + + private PostAttendancePolicyDto extractPolicy(Object dto) { + if (dto instanceof CreatePostRequest create) { + return create.getAttendancePolicy(); + } else if (dto instanceof UpdatePostRequest update) { + return update.getAttendancePolicy(); + } else { + return null; + } + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/validator/ValidAttendancePolicy.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/validator/ValidAttendancePolicy.java new file mode 100644 index 0000000..e44f587 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/validator/ValidAttendancePolicy.java @@ -0,0 +1,19 @@ +package com.example.emergencyassistb4b4.domain.volunteer.dto.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.*; + +@Target({ ElementType.TYPE }) // DTO 클래스 단위에서 검사 +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = AttendancePolicyConstraintValidator.class) +@Documented +public @interface ValidAttendancePolicy { + + String message() default "출석 정책 값이 유효하지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} + From dec5a57a09ee171331c117fb57559f25c42c2174 Mon Sep 17 00:00:00 2001 From: rabitis99 Date: Wed, 10 Sep 2025 10:16:16 +0900 Subject: [PATCH 2/8] =?UTF-8?q?FCM=20=EC=B7=A8=EC=86=8C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/volunteer/VolunteerAlert.java | 2 + .../domain/alert/dto/fcm/FcmMessageDto.java | 23 +++++++ .../volunteer/VolunteerCancelAlertDto.java | 39 +++++++++++ .../volunteer/VolunteerUpdateAlertDto.java | 25 +++++--- .../domain/alert/fcm/sender/FcmSender.java | 15 +++++ .../config/error/KafkaErrorHandlerConfig.java | 7 ++ .../VolunteerCanceledAlertListenerConfig.java | 41 ++++++++++++ .../dlt/VolunteerCancelAlertDltHandler.java | 64 +++++++++++++++++++ .../VolunteerCanceledEventListener.java | 41 ++++++++++++ ...lunteerCancelAlertOrchestratorService.java | 62 ++++++++++++++++++ ...lunteerUpdateAlertOrchestratorService.java | 3 +- .../service/command/AlertCommandService.java | 10 +++ .../infra/redis/service/TTLRedisService.java | 20 +++++- .../VolunteerCancelEventProducer.java | 31 +++++++++ .../VolunteerUpdatedEventProducer.java | 3 + .../service/VolunteerPostService.java | 31 +++++++-- .../kafka/dto/VolunteerCancelEvent.java | 29 +++++++++ .../global/status/ErrorStatus.java | 1 + 18 files changed, 430 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/volunteer/VolunteerCancelAlertDto.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/config/listener/VolunteerCanceledAlertListenerConfig.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/consumer/dlt/VolunteerCancelAlertDltHandler.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/consumer/listener/VolunteerCanceledEventListener.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/alert/orchestrator/VolunteerCancelAlertOrchestratorService.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/volunteer/kafka/producer/VolunteerCancelEventProducer.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/global/kafka/dto/VolunteerCancelEvent.java diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/domain/volunteer/VolunteerAlert.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/domain/volunteer/VolunteerAlert.java index 10b04de..564e50a 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/alert/domain/volunteer/VolunteerAlert.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/domain/volunteer/VolunteerAlert.java @@ -44,4 +44,6 @@ public class VolunteerAlert extends BaseEntity { @Column(nullable = false) private LocalDateTime volunteerDate; + private String subtype; + } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/fcm/FcmMessageDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/fcm/FcmMessageDto.java index b235233..08a2f28 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/fcm/FcmMessageDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/fcm/FcmMessageDto.java @@ -2,6 +2,7 @@ import com.example.emergencyassistb4b4.domain.alert.dto.report.ReportThresholdAlertDto; import com.example.emergencyassistb4b4.domain.alert.dto.report.ReportImmediateAlertDto; +import com.example.emergencyassistb4b4.domain.alert.dto.volunteer.VolunteerCancelAlertDto; import com.example.emergencyassistb4b4.domain.alert.dto.volunteer.VolunteerUpdateAlertDto; import java.time.format.DateTimeFormatter; import lombok.Builder; @@ -93,4 +94,26 @@ public static FcmMessageDto fromVolunteerUpdateAlert(VolunteerUpdateAlertDto ale .body(body) .build(); } + public static FcmMessageDto fromVolunteerCancelAlert(VolunteerCancelAlertDto alert) { + + String title = String.format( + "[봉사 알림] %s 취소 공지", alert.getTitle() + ); + + String body = String.format( + """ + 게시글명 : %s + 취소 장소 : %s + 취소 시간 : %s + """, + alert.getTitle(), + alert.getPlaceName(), + alert.getVolunteerDate().format(DateTimeFormatter.ofPattern("MM월 dd일 HH:mm")) + ); + + return FcmMessageDto.builder() + .title(title) + .body(body) + .build(); + } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/volunteer/VolunteerCancelAlertDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/volunteer/VolunteerCancelAlertDto.java new file mode 100644 index 0000000..3e1bb6e --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/volunteer/VolunteerCancelAlertDto.java @@ -0,0 +1,39 @@ +package com.example.emergencyassistb4b4.domain.alert.dto.volunteer; + +import com.example.emergencyassistb4b4.domain.alert.domain.volunteer.VolunteerAlert; +import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerCancelEvent; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + + +@Getter +@Builder +public class VolunteerCancelAlertDto { + private final Long postId; + private final String title; + private final String placeName; + private LocalDateTime volunteerDate; + private String subtype; + + public static VolunteerCancelAlertDto from(VolunteerCancelEvent event,String subtype) { + + return VolunteerCancelAlertDto .builder() + .postId(event.getPostId()) + .title(event.getTitle()) + .placeName(event.getPlaceName()) + .volunteerDate(event.getVolunteerDate()) + .subtype(subtype) + .build(); + } + + public VolunteerAlert toEntity() { + return VolunteerAlert.builder() + .title(this.title) + .placeName(this.placeName) + .volunteerDate(this.volunteerDate) + .subtype(this.subtype) + .build(); + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/volunteer/VolunteerUpdateAlertDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/volunteer/VolunteerUpdateAlertDto.java index 09903d7..a2c0d30 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/volunteer/VolunteerUpdateAlertDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/dto/volunteer/VolunteerUpdateAlertDto.java @@ -2,7 +2,9 @@ import com.example.emergencyassistb4b4.domain.alert.domain.volunteer.VolunteerAlert; import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerUpdatedEvent; + import java.time.LocalDateTime; + import lombok.Builder; import lombok.Getter; @@ -14,22 +16,25 @@ public class VolunteerUpdateAlertDto { private final String title; private final String placeName; private LocalDateTime volunteerDate; + private String subtype; - public static VolunteerUpdateAlertDto from(VolunteerUpdatedEvent event) { + public static VolunteerUpdateAlertDto from(VolunteerUpdatedEvent event, String subtype) { return VolunteerUpdateAlertDto.builder() - .postId(event.getPostId()) - .title(event.getTitle()) - .placeName(event.getPlaceName()) - .volunteerDate(event.getVolunteerDate()) - .build(); + .postId(event.getPostId()) + .title(event.getTitle()) + .placeName(event.getPlaceName()) + .volunteerDate(event.getVolunteerDate()) + .subtype(subtype) + .build(); } public VolunteerAlert toEntity() { return VolunteerAlert.builder() - .title(this.title) - .placeName(this.placeName) - .volunteerDate(this.volunteerDate) - .build(); + .title(this.title) + .placeName(this.placeName) + .volunteerDate(this.volunteerDate) + .subtype(this.subtype) + .build(); } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/fcm/sender/FcmSender.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/fcm/sender/FcmSender.java index fc085dd..f332845 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/alert/fcm/sender/FcmSender.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/fcm/sender/FcmSender.java @@ -131,4 +131,19 @@ public void sendVolunteerUpdateAlert(FcmMessageDto dto, List tokens) { log.error("FCM 메시지 전송 실패", e); } } + public void sendVolunteerCancelAlert(FcmMessageDto dto, List tokens) { + + // 봉사 참여자 + MulticastMessage message = MulticastMessage.builder() + .addAllTokens(tokens) + .setNotification(buildNotification(dto)) + .setAndroidConfig(buildAndroidConfig()) + .build(); + + try { + fcm.sendEachForMulticastAsync(message); + } catch (Exception e) { + log.error("FCM 메시지 전송 실패", e); + } + } } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/config/error/KafkaErrorHandlerConfig.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/config/error/KafkaErrorHandlerConfig.java index 059694b..871fa52 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/config/error/KafkaErrorHandlerConfig.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/config/error/KafkaErrorHandlerConfig.java @@ -37,6 +37,12 @@ public class KafkaErrorHandlerConfig { // CommonErrorHandler 빈 제공 (DLT 라 @Value("${spring.kafka.topic.dlt.volunteer}") private String volunteerDltTopic; + @Value("${spring.kafka.topic.volunteerCancel}") + private String volunteerCancelTopic; + + @Value("${spring.kafka.topic.dlt.volunteerCancel}") + private String volunteerCancelDltTopic; + private final KafkaTemplate kafkaTemplate; @Bean @@ -46,6 +52,7 @@ public CommonErrorHandler commonErrorHandler() { bizDltMap.put(immediateTopic, immediateDltTopic); bizDltMap.put(thresholdTopic, thresholdDltTopic); bizDltMap.put(volunteerTopic, volunteerDltTopic); + bizDltMap.put(volunteerCancelTopic, volunteerCancelDltTopic); DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer( kafkaTemplate, diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/config/listener/VolunteerCanceledAlertListenerConfig.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/config/listener/VolunteerCanceledAlertListenerConfig.java new file mode 100644 index 0000000..1c64e59 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/config/listener/VolunteerCanceledAlertListenerConfig.java @@ -0,0 +1,41 @@ +package com.example.emergencyassistb4b4.domain.alert.kafka.config.listener; + +import com.example.emergencyassistb4b4.domain.alert.kafka.config.consumer.KafkaConsumerConfig; +import com.example.emergencyassistb4b4.domain.alert.kafka.config.error.KafkaErrorHandlerConfig; +import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerCancelEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; + +@Configuration +@RequiredArgsConstructor +public class VolunteerCanceledAlertListenerConfig { + + private final KafkaConsumerConfig consumerConfig; + private final KafkaErrorHandlerConfig errorHandlerConfig; + + @Bean + public ConsumerFactory VolunteerCancelConsumerFactory() { + + return new DefaultKafkaConsumerFactory<>( + consumerConfig.baseConsumerProps(null, VolunteerCancelEvent.class.getName()) + ); + } + + @Bean(name = "volunteerCanceledListenerFactory") + public ConcurrentKafkaListenerContainerFactory volunteerUpdateListenerFactory() { + + var factory = new ConcurrentKafkaListenerContainerFactory(); + + factory.setConsumerFactory(VolunteerCancelConsumerFactory()); + factory.setConcurrency(3); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD); + factory.setCommonErrorHandler(errorHandlerConfig.commonErrorHandler()); + + return factory; + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/consumer/dlt/VolunteerCancelAlertDltHandler.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/consumer/dlt/VolunteerCancelAlertDltHandler.java new file mode 100644 index 0000000..b8a17f0 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/consumer/dlt/VolunteerCancelAlertDltHandler.java @@ -0,0 +1,64 @@ +package com.example.emergencyassistb4b4.domain.alert.kafka.consumer.dlt; + +import com.example.emergencyassistb4b4.domain.alert.kafka.service.KafkaDltLogService; +import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerCancelEvent; +import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerUpdatedEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@RequiredArgsConstructor +@Component +public class VolunteerCancelAlertDltHandler { + + private final ObjectMapper objectMapper; + private final KafkaDltLogService kafkaDltLogService; + + // 원본(정상) 토픽/그룹 – 실패 로그 기록용 + @Value("${spring.kafka.topic.volunteerCancel}") + private String volunteerTopic; + + @Value("${spring.kafka.group.volunteerCancel") + private String volunteerGroup; + + // DLT 토픽 – 리스너 구독용(주석용으로도 사용 가능) + @Value("${spring.kafka.topic.dlt.volunteerCancel}") + private String volunteerDltTopic; + + @KafkaListener( + topics = "${spring.kafka.topic.dlt.volunteerCancel}", + groupId = "${spring.kafka.group.dlt.volunteerCancel}", + containerFactory = "dltListenerFactory" + ) + public void handle(String rawMessage) { + + final String listener = "volunteerCanceledEventListener"; + + VolunteerCancelEvent parsedEvent = null; + try { + parsedEvent = objectMapper.readValue(rawMessage, VolunteerCancelEvent.class); + } catch (Exception e) { + log.error("[DLT:봉사글] 역직렬화 실패 - 리스너: {}, 이유: {}", listener, e.getMessage()); + + kafkaDltLogService.logFailure( + volunteerTopic, + volunteerGroup, + rawMessage, + "역직렬화 실패로 인해 DLQ 메시지 파싱 불가", + listener, + e.getClass().getSimpleName() + ": " + e.getMessage(), + LocalDateTime.now() + ); + + return; + } + + log.warn("[DLT:봉사글 취소] 역직렬화 성공 - 비즈니스 로직 처리 중 예외 가능성: {}", parsedEvent); + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/consumer/listener/VolunteerCanceledEventListener.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/consumer/listener/VolunteerCanceledEventListener.java new file mode 100644 index 0000000..5a3cc0a --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/kafka/consumer/listener/VolunteerCanceledEventListener.java @@ -0,0 +1,41 @@ +package com.example.emergencyassistb4b4.domain.alert.kafka.consumer.listener; + + +import com.example.emergencyassistb4b4.domain.alert.orchestrator.VolunteerCancelAlertOrchestratorService; +import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerCancelEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class VolunteerCanceledEventListener { + private final VolunteerCancelAlertOrchestratorService orchestratorService; + + @KafkaListener( + topics = "${spring.kafka.topic.volunteerCancel}", + groupId = "${spring.kafka.group.volunteerCancel}", + containerFactory = "volunteerUpdatedListenerFactory" + ) + public void onVolunteerCanceled( + VolunteerCancelEvent event, + @Header(KafkaHeaders.RECEIVED_TOPIC) String topic, + @Header(KafkaHeaders.RECEIVED_PARTITION) int partition, + @Header(KafkaHeaders.OFFSET) long offset + ) { + + log.info("[VOLUNTEER] consumed topic={}, partition={}, offset={}, payload={}", topic, partition, offset, event); + + try { + orchestratorService.process(event); + } catch (Exception e) { + log.error("[봉사 게시글 취소 알림 처리 실패] postId={}, title={}", + event.getPostId(), event.getTitle(), e); + throw e; + } + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/orchestrator/VolunteerCancelAlertOrchestratorService.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/orchestrator/VolunteerCancelAlertOrchestratorService.java new file mode 100644 index 0000000..657e810 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/orchestrator/VolunteerCancelAlertOrchestratorService.java @@ -0,0 +1,62 @@ +package com.example.emergencyassistb4b4.domain.alert.orchestrator; + +import com.example.emergencyassistb4b4.domain.alert.dto.fcm.FcmMessageDto; +import com.example.emergencyassistb4b4.domain.alert.dto.volunteer.VolunteerCancelAlertDto; +import com.example.emergencyassistb4b4.domain.alert.fcm.sender.FcmSender; +import com.example.emergencyassistb4b4.domain.alert.service.command.AlertCommandService; +import com.example.emergencyassistb4b4.domain.userDevice.service.UserDeviceService; +import com.example.emergencyassistb4b4.domain.volunteer.service.VolunteerParticipantService; +import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerCancelEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +@Slf4j +@Service +@RequiredArgsConstructor +public class VolunteerCancelAlertOrchestratorService { + + private final VolunteerParticipantService volunteerParticipantService; + private final AlertCommandService alertCommandService; + private final UserDeviceService userDeviceService; + private final FcmSender fcmSender; + private final static String subtype= "cancel"; + + @Transactional + public void process(VolunteerCancelEvent event) { + + // 1. VolunteerUpdatedEvent -> VolunteerAlertDto + VolunteerCancelAlertDto info = VolunteerCancelAlertDto.from(event,subtype); + + // 2. FCM 메시지 생성 + FcmMessageDto message = FcmMessageDto.fromVolunteerCancelAlert(info); + + // 3. 봉사활동 참여자 조회 + List participants = volunteerParticipantService.findParticipants(info.getPostId()); + if (participants == null || participants.isEmpty()) { + // 참여자 없을 경우 메시지 발송 x + return; + } + // 4. FCM token 조회 + List tokens = userDeviceService.findFcmTokensByUserIds(participants); + + + // 5. FCM 발송 + try { + fcmSender.sendVolunteerCancelAlert(message, tokens); + } catch (Exception e) { + log.error("봉사 게시글 수정 알림 발송 실패 - postId={}, title={}", info.getPostId(), info.getTitle(), e); + // fcm 전송 실패하더라도 DB에 알림 이력은 저장되도록 함. + } + + // 6. 알림 저장 + try { + alertCommandService.saveVolunteerCancelAlert(info, participants); + } catch (Exception e) { + log.error("알림 이력 저장 실패 - postId={}, participantCount={}", info.getPostId(), participants.size(), e); + throw e; + } + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/orchestrator/VolunteerUpdateAlertOrchestratorService.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/orchestrator/VolunteerUpdateAlertOrchestratorService.java index b614e7a..085973d 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/alert/orchestrator/VolunteerUpdateAlertOrchestratorService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/orchestrator/VolunteerUpdateAlertOrchestratorService.java @@ -22,12 +22,13 @@ public class VolunteerUpdateAlertOrchestratorService { private final AlertCommandService alertCommandService; private final UserDeviceService userDeviceService; private final FcmSender fcmSender; + private final static String subtype= "update"; @Transactional public void process(VolunteerUpdatedEvent event) { // 1. VolunteerUpdatedEvent -> VolunteerAlertDto - VolunteerUpdateAlertDto info = VolunteerUpdateAlertDto.from(event); + VolunteerUpdateAlertDto info = VolunteerUpdateAlertDto.from(event,subtype); // 2. FCM 메시지 생성 FcmMessageDto message = FcmMessageDto.fromVolunteerUpdateAlert(info); diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/service/command/AlertCommandService.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/service/command/AlertCommandService.java index 6b30b6c..ee6375d 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/alert/service/command/AlertCommandService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/service/command/AlertCommandService.java @@ -5,6 +5,7 @@ import com.example.emergencyassistb4b4.domain.alert.domain.volunteer.UserVolunteerAlert; import com.example.emergencyassistb4b4.domain.alert.domain.volunteer.VolunteerAlert; import com.example.emergencyassistb4b4.domain.alert.dto.report.ReportThresholdAlertDto; +import com.example.emergencyassistb4b4.domain.alert.dto.volunteer.VolunteerCancelAlertDto; import com.example.emergencyassistb4b4.domain.alert.dto.volunteer.VolunteerUpdateAlertDto; import com.example.emergencyassistb4b4.domain.alert.repository.report.ReportAlertRepository; import com.example.emergencyassistb4b4.domain.alert.repository.volunteer.UserVolunteerAlertRepository; @@ -42,4 +43,13 @@ public void saveVolunteerUpdateAlert(VolunteerUpdateAlertDto dto, List par // 2. UserReportAlert 생성 및 일괄 저장 userVolunteerAlertRepository.saveAll(UserVolunteerAlert.from(alert, participants)); } + + public void saveVolunteerCancelAlert(VolunteerCancelAlertDto dto, List participants) { + + // 1. ReportAlert 생성 및 저장 + VolunteerAlert alert = volunteerAlertRepository.save(dto.toEntity()); + + // 2. UserReportAlert 생성 및 일괄 저장 + userVolunteerAlertRepository.saveAll(UserVolunteerAlert.from(alert, participants)); + } } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/infra/redis/service/TTLRedisService.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/infra/redis/service/TTLRedisService.java index a959bf7..bc4b5ee 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/infra/redis/service/TTLRedisService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/infra/redis/service/TTLRedisService.java @@ -1,10 +1,14 @@ package com.example.emergencyassistb4b4.domain.volunteer.infra.redis.service; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.Cursor; + import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; - import java.time.Duration; +import java.util.Objects; import java.util.concurrent.TimeUnit; @Service @@ -55,4 +59,18 @@ public void deleteTeamKeys(Long postId, Long teamId, Long userId) { redisTemplate.delete(duplicateKey); } } + + + public void deleteAllKeysByPostId(Long postId) { + String pattern = "team:" + postId + ":*"; + ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build(); + + var connection = Objects.requireNonNull(redisTemplate.getConnectionFactory()).getConnection(); + try (Cursor cursor = connection.keyCommands().scan(options)) { + while (cursor.hasNext()) { + byte[] keyBytes = cursor.next(); + redisTemplate.delete(new String(keyBytes)); + } + } + } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/kafka/producer/VolunteerCancelEventProducer.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/kafka/producer/VolunteerCancelEventProducer.java new file mode 100644 index 0000000..4c51fcf --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/kafka/producer/VolunteerCancelEventProducer.java @@ -0,0 +1,31 @@ +package com.example.emergencyassistb4b4.domain.volunteer.kafka.producer; + +import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerCancelEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@RequiredArgsConstructor +@Component +public class VolunteerCancelEventProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${spring.kafka.topic.dlt.volunteerCancel}") + private String topic; + + public void sendVolunteerCanceledEvent(VolunteerCancelEvent event) { + + kafkaTemplate.send(topic, event) + .thenAccept(result -> log.info("kafka - volunteer-post-canceled 발행 성공: {}", event)) + .exceptionally(ex -> { + log.error("kafka - volunteer-post-canceled 발행 실패: {}", event, ex); + return null; + }); + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/kafka/producer/VolunteerUpdatedEventProducer.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/kafka/producer/VolunteerUpdatedEventProducer.java index 79b75be..6a88cd9 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/kafka/producer/VolunteerUpdatedEventProducer.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/kafka/producer/VolunteerUpdatedEventProducer.java @@ -6,6 +6,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; @Slf4j @RequiredArgsConstructor @@ -17,6 +19,7 @@ public class VolunteerUpdatedEventProducer { @Value("${spring.kafka.topic.volunteer}") private String topic; + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void sendVolunteerUpdatedEvent(VolunteerUpdatedEvent event) { kafkaTemplate.send(topic, event) diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java index 804f65b..fd745fb 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java @@ -7,10 +7,13 @@ import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.*; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common.AttendancePolicyProvider; import com.example.emergencyassistb4b4.domain.volunteer.enums.CheckinStatus; +import com.example.emergencyassistb4b4.domain.volunteer.infra.redis.service.TTLRedisService; import com.example.emergencyassistb4b4.domain.volunteer.infra.redis.service.TeamParticipationRedisService; import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; +import com.example.emergencyassistb4b4.domain.volunteer.kafka.producer.VolunteerCancelEventProducer; import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerParticipantRepository; import com.example.emergencyassistb4b4.global.exception.ApiException; +import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerCancelEvent; import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerUpdatedEvent; import com.example.emergencyassistb4b4.global.status.ErrorStatus; import com.example.emergencyassistb4b4.domain.user.domain.User; @@ -21,6 +24,7 @@ import com.example.emergencyassistb4b4.domain.volunteer.kafka.producer.VolunteerUpdatedEventProducer; import com.example.emergencyassistb4b4.domain.volunteer.repository.PostRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; @@ -33,6 +37,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class VolunteerPostService { private final UserRepository userRepository; @@ -40,6 +45,8 @@ public class VolunteerPostService { private final TeamParticipationRedisService teamParticipationRedisService; private final VolunteerUpdatedEventProducer producer; private final AttendanceEventListener attendanceEventListener; + private final VolunteerCancelEventProducer volunteerCancelEventProducer; + private final TTLRedisService ttlRedisService; // 모집 게시글 생성 @Transactional @@ -73,10 +80,13 @@ public void updatePost(Long userId, Long postId, UpdatePostRequest request) { // 업데이트 post.update(request); + // kafka 메세지 발행 + VolunteerUpdatedEvent event = VolunteerUpdatedEvent.from(post); + scheduleAttendanceForTeams(post.getTeams(), request.getAttendancePolicy()); - // kafka 메세지 발행 - producer.sendVolunteerUpdatedEvent(VolunteerUpdatedEvent.from(post)); + producer.sendVolunteerUpdatedEvent(event); + } // 모집 게시글 다건 조회 @@ -108,7 +118,6 @@ public Slice getMyPostList(Long userId, PostFilterRequest fil throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); } - Slice posts = postRepository.findPosts(userId, filter, pageable); return posts.map(post -> { @@ -130,19 +139,31 @@ public PostDetailResponse getPost(Long postId) { return PostDetailResponse.from(post); } + @Transactional public void deleteMyPost(Long userId, Long postId) { Post post = postRepository.findById(postId) - .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); + .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); if (!post.getUser().getId().equals(userId)) { throw new ApiException(ErrorStatus.FORBIDDEN); } - // 게시글 상태가 모집 중일 경우에만 삭제 가능 if (post.getStatus() != PostStatus.OPEN) { throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); } + VolunteerCancelEvent event = VolunteerCancelEvent.from(post); + + try { + // Kafka 전송 성공해야 다음으로 진행 + volunteerCancelEventProducer.sendVolunteerCanceledEvent(event); + log.info("Kafka 발행 성공: {}", event); + } catch (Exception e) { + log.error("Kafka 발행 실패, 롤백 처리: {}", event, e); + throw new ApiException(ErrorStatus.KAFKA_SEND_FAILED); + } + + ttlRedisService.deleteAllKeysByPostId(postId); postRepository.delete(post); } diff --git a/src/main/java/com/example/emergencyassistb4b4/global/kafka/dto/VolunteerCancelEvent.java b/src/main/java/com/example/emergencyassistb4b4/global/kafka/dto/VolunteerCancelEvent.java new file mode 100644 index 0000000..a594be1 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/global/kafka/dto/VolunteerCancelEvent.java @@ -0,0 +1,29 @@ +package com.example.emergencyassistb4b4.global.kafka.dto; + +import com.example.emergencyassistb4b4.domain.volunteer.domain.Post; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class VolunteerCancelEvent { + private Long postId; + private String title; + private String placeName; + private LocalDateTime volunteerDate; + + public static VolunteerCancelEvent from(Post post) { + + return VolunteerCancelEvent.builder() + .postId(post.getId()) + .title(post.getTitle()) + .placeName(post.getLocation().getPlaceName()) + .volunteerDate(LocalDateTime.of(post.getVolunteerDate(), post.getVolunteerStartTime())) + .build(); + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/global/status/ErrorStatus.java b/src/main/java/com/example/emergencyassistb4b4/global/status/ErrorStatus.java index 4f02c14..0dd7e59 100644 --- a/src/main/java/com/example/emergencyassistb4b4/global/status/ErrorStatus.java +++ b/src/main/java/com/example/emergencyassistb4b4/global/status/ErrorStatus.java @@ -82,6 +82,7 @@ public enum ErrorStatus implements BaseErrorCode { KAKAO_API_RESPONSE_PARSE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "LC010", "카카오 API 응답 파싱 실패"), KAKAO_API_RESPONSE_STATUS_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "LC010", "카카오 API 비정상 응답"), + KAFKA_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "RE010", "카프카 서버 오류 발생"), ; private final HttpStatus httpStatus; From 641ebe89c42d0c6beb5c7b05e48acd24efd7d911 Mon Sep 17 00:00:00 2001 From: rabitis99 Date: Thu, 11 Sep 2025 21:50:21 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=EC=99=84=EB=B2=BD=ED=95=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../volunteer/repository/PostRepository.java | 5 +++++ .../volunteer/service/VolunteerPostService.java | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java index b4b8df7..91ee9f5 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java @@ -63,5 +63,10 @@ Optional findParticipantInTeam(@Param("postId") Long postI @Param("participantId") Long participantId); + @Query("select p from Post p left join fetch p.teams where p.id = :postId") + Optional findByIdWithTeams(@Param("postId") Long postId); + + + } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java index fd745fb..9d29292 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java @@ -2,6 +2,7 @@ import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.event.AttendanceEventListener; import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.event.AttendanceStateSetEvent; +import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerParticipant; import com.example.emergencyassistb4b4.domain.volunteer.dto.Join.CheckinStatusRequest; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.*; @@ -47,6 +48,7 @@ public class VolunteerPostService { private final AttendanceEventListener attendanceEventListener; private final VolunteerCancelEventProducer volunteerCancelEventProducer; private final TTLRedisService ttlRedisService; + private final RabbitMQRedisService rabbitMQRedisService; // 모집 게시글 생성 @Transactional @@ -141,7 +143,7 @@ public PostDetailResponse getPost(Long postId) { @Transactional public void deleteMyPost(Long userId, Long postId) { - Post post = postRepository.findById(postId) + Post post = postRepository.findByIdWithTeams(postId) .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); if (!post.getUser().getId().equals(userId)) { @@ -163,6 +165,16 @@ public void deleteMyPost(Long userId, Long postId) { throw new ApiException(ErrorStatus.KAFKA_SEND_FAILED); } + + for (VolunteerTeam volunteerTeam: post.getTeams()){ + try { + rabbitMQRedisService.clearTrackingState(volunteerTeam.getId()); + } catch (Exception e) { + log.error("TrackingState 삭제 실패 teamId={} : {}", volunteerTeam.getId(), e.getMessage()); + // 필요시 재시도 큐나 DLQ로 이동 + } + } + ttlRedisService.deleteAllKeysByPostId(postId); postRepository.delete(post); } From a72e16b3350b8b0b8da20e9bae5ca1e757759e77 Mon Sep 17 00:00:00 2001 From: rabitis99 Date: Sat, 13 Sep 2025 11:28:46 +0900 Subject: [PATCH 4/8] RabbitMQ Publisher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Value → 프로퍼티 주입 MessagePostProcessor 별도 메서드로 분리 retry 로직 유지 TrackingDataService flatMap(Optional::stream)로 Optional 처리 단순화 불필요한 null 체크 제거 및 forEach 사용 TrackingService Optional 예외 처리 시 메시지 명시 (TEAM_NOT_FOUND) 변수명/로직 정리, 로직은 그대로 WebSocketHandler / TrackingSocketHandler 다중 세션 → 단일 세션 처리로 단순화 실패 시 Redis fallback 코드 정리, 로그 개선 LocationWebSocketService 구조·TTL 계산, Redis 처리 로직 정리 로직 자체는 변경 없음 --- .../rabbitmq/consumer/TrackingListener.java | 23 ++--- .../dto/IndividualTrackingSessionDto.java | 15 ++++ .../event/AttendanceEventListener.java | 48 +++++++---- .../publisher/TrackingSessionPublisher.java | 35 +++++--- .../rabbitmq/service/TrackingDataService.java | 14 ++-- .../rabbitmq/service/TrackingService.java | 11 +-- .../redis/RabbitMQRedisScheduler.java | 3 - .../redis/RabbitMQRedisService.java | 2 - .../LocationTrackingWebSocketHandler.java | 83 ++++++++++++------- .../socket/handler/TrackingSocketHandler.java | 48 +++++------ .../service/LocationWebSocketService.java | 70 ++++++++-------- .../service/VolunteerJoinService.java | 2 + .../global/status/ErrorStatus.java | 1 + 13 files changed, 201 insertions(+), 154 deletions(-) diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/consumer/TrackingListener.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/consumer/TrackingListener.java index ef5c488..7b27e66 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/consumer/TrackingListener.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/consumer/TrackingListener.java @@ -6,6 +6,8 @@ import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.IndividualTrackingSessionDto; import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.service.TrackingDataService; import com.example.emergencyassistb4b4.domain.attendance.socket.handler.TrackingSocketHandler; + +import static com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.IndividualTrackingSessionDto.buildIndividualDto; import static com.example.emergencyassistb4b4.domain.attendance.rabbitmq.util.RabbitMqUtils.isValidMessage; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -48,10 +50,9 @@ public void onMessage(MessageWrapper message, Channel channel, List participantIds = dto.getParticipantUserIds(); switch (state) { - case READY -> sendTypedMessageToVolunteers(participantIds, "READY", dto); - case STARTED -> sendTypedMessageToVolunteers(participantIds, "STARTED", dto); + case READY, STARTED -> sendTypedMessageToVolunteers(participantIds, state, dto); case ENDED -> { - sendTypedMessageToVolunteers(participantIds, "ENDED", dto); + sendTypedMessageToVolunteers(participantIds, state, dto); trackingService.saveSessionAttendanceData(participantIds, dto.getTeamId()); participantIds.forEach(socketHandler::removeVolunteerUserMapping); } @@ -68,20 +69,10 @@ public void onMessage(MessageWrapper message, Channel channel, } } - private void sendTypedMessageToVolunteers(List volunteerIds, String type, TrackingSessionDto dto) { + private void sendTypedMessageToVolunteers(List volunteerIds, SessionState state, TrackingSessionDto dto) { for (Long volunteerId : volunteerIds) { - IndividualTrackingSessionDto individualDto = IndividualTrackingSessionDto.builder() - .teamId(dto.getTeamId()) - .startTime(dto.getStartTime()) - .endTime(dto.getEndTime()) - .targetLat(dto.getTargetLat()) - .targetLng(dto.getTargetLng()) - .meter(dto.getMeter()) - .intervalSeconds(dto.getIntervalSeconds()) - .participantUserId(volunteerId) - .build(); - - socketHandler.sendToUser(volunteerId, type, individualDto); + socketHandler.sendToUser(volunteerId, state.name(), buildIndividualDto(dto, volunteerId)); } } + } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/IndividualTrackingSessionDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/IndividualTrackingSessionDto.java index 7771708..bcb99e2 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/IndividualTrackingSessionDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/IndividualTrackingSessionDto.java @@ -29,4 +29,19 @@ public class IndividualTrackingSessionDto { private long intervalSeconds; private Long participantUserId; + + public static IndividualTrackingSessionDto buildIndividualDto(TrackingSessionDto dto, Long volunteerId) { + return IndividualTrackingSessionDto.builder() + .teamId(dto.getTeamId()) + .startTime(dto.getStartTime()) + .endTime(dto.getEndTime()) + .targetLat(dto.getTargetLat()) + .targetLng(dto.getTargetLng()) + .meter(dto.getMeter()) + .intervalSeconds(dto.getIntervalSeconds()) + .participantUserId(volunteerId) + .build(); + } + + } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceEventListener.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceEventListener.java index 6b00111..1f354ad 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceEventListener.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceEventListener.java @@ -3,6 +3,7 @@ import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.RabbitMQ; import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.service.TrackingService; import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; +import io.lettuce.core.RedisException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.retry.annotation.Backoff; @@ -11,6 +12,7 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; + import java.time.LocalDateTime; @Component @@ -26,31 +28,29 @@ public class AttendanceEventListener { @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 5000)) public void onAttendanceStateSet(AttendanceStateSetEvent event) { try { - rabbitMQRedisService.scheduleTrackingStart(event.getTeamId(), event.getJoinedAt()); // state = false, joinedAt 저장 - log.info("[예약 등록] 출석 예약 저장 완료 - teamId: {}, joinedAt: {}", event.getTeamId(), event.getJoinedAt()); + rabbitMQRedisService.scheduleTrackingStart(event.getTeamId(), event.getJoinedAt()); } catch (Exception e) { log.error("[예약 등록 실패] teamId: {}", event.getTeamId(), e); } } - // 2. 출석 시작 처리 (예약 시간 5분 전) + // 2. 출석 시작 처리 public void onAttendanceStateChanged(Long teamId) { try { RabbitMQ state = rabbitMQRedisService.getTrackingState(teamId); - boolean isTimeToTrigger = LocalDateTime.now().isAfter(state.getJoinedAt().minusMinutes(5)); - boolean isNotStarted = !state.isState(); - - if (isTimeToTrigger && isNotStarted) { + if (shouldStart(state.getJoinedAt()) && !state.isState()) { trackingService.scheduleTrackingForTeam(teamId); rabbitMQRedisService.updateTrackingState(teamId, state.getJoinedAt()); // state = true log.info("[출석 시작] 출석 상태 변경 완료 - teamId: {}", teamId); } else { - log.debug("[조건 미충족] 출석 시작 안함 - teamId: {}, isTimeToTrigger: {}, isNotStarted: {}", - teamId, isTimeToTrigger, isNotStarted); + log.debug("[조건 미충족] 출석 시작 안함 - teamId: {}, shouldStart: {}, isNotStarted: {}", + teamId, shouldStart(state.getJoinedAt()), !state.isState()); } + } catch (RedisException e) { + log.error("[출석 시작 실패 - Redis 문제] teamId: {}", teamId, e); } catch (Exception e) { - log.error("[출석 시작 실패] teamId: {}", teamId, e); + log.error("[출석 시작 실패 - 알 수 없는 오류] teamId: {}", teamId, e); } } @@ -60,16 +60,32 @@ public void onAttendanceEnded(Long teamId) { RabbitMQ state = rabbitMQRedisService.getTrackingState(teamId); if (state.isState()) { - rabbitMQRedisService.clearTrackingState(teamId); - log.info("[출석 종료] 출석 상태 삭제 완료 - teamId: {}", teamId); - } else if (LocalDateTime.now().isAfter(state.getJoinedAt())) { - rabbitMQRedisService.clearTrackingState(teamId); - log.warn("[출석 종료 비정상] 출석이 시작되지 않은 상태에서 종료 요청 - teamId: {}", teamId); + clearTrackingStateWithLog(teamId, "[출석 종료] 출석 상태 삭제 완료", false); + } else if (shouldEnd(state.getJoinedAt())) { + clearTrackingStateWithLog(teamId, "[출석 종료 비정상] 출석 시작 안됨", true); } else { log.debug("[출석 종료 스킵] 아직 출석 시간 전 - teamId: {}, joinedAt: {}", teamId, state.getJoinedAt()); } + + } catch (RedisException e) { + log.error("[출석 종료 실패 - Redis 문제] teamId: {}", teamId, e); } catch (Exception e) { - log.error("[출석 종료 실패] teamId: {}", teamId, e); + log.error("[출석 종료 실패 - 알 수 없는 오류] teamId: {}", teamId, e); } } + + // ---------------- 유틸 메서드 ---------------- + + private void clearTrackingStateWithLog(Long teamId, String message, boolean warn) { + rabbitMQRedisService.clearTrackingState(teamId); + if (warn) log.warn(message, teamId); + } + + private boolean shouldStart(LocalDateTime joinedAt) { + return LocalDateTime.now().isAfter(joinedAt.minusMinutes(5)); + } + + private boolean shouldEnd(LocalDateTime joinedAt) { + return LocalDateTime.now().isAfter(joinedAt); + } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/publisher/TrackingSessionPublisher.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/publisher/TrackingSessionPublisher.java index 7de4276..744282c 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/publisher/TrackingSessionPublisher.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/publisher/TrackingSessionPublisher.java @@ -2,11 +2,14 @@ import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.MessageWrapper; import static com.example.emergencyassistb4b4.domain.attendance.rabbitmq.util.RabbitMqUtils.isValidMessage; +import org.springframework.beans.factory.annotation.Value; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.AmqpException; import org.springframework.amqp.core.MessagePostProcessor; import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; @Service @@ -16,34 +19,40 @@ public class TrackingSessionPublisher { private final RabbitTemplate rabbitTemplate; - private static final String DELAYED_EXCHANGE_NAME = "tracking.delay.exchange"; - private static final String DELAYED_ROUTING_KEY = "tracking.delay.routingkey"; + @Value("${spring.rabbitmq.tracking.delayed-exchange}") + private String delayedExchangeName; + + @Value("${spring.rabbitmq.tracking.delayed-routing-key}") + private String delayedRoutingKey; /** * 지연 메시지 전송 */ + @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 2000)) public void publishDelayedTrackingSession(MessageWrapper messageWrapper, long delayMillis) { if (!isValidMessage(messageWrapper)) { log.warn("발행하려는 메시지가 유효하지 않습니다: {}", messageWrapper); return; } - try { - MessagePostProcessor messagePostProcessor = message -> { - message.getMessageProperties().setHeader("x-delay", delayMillis); - return message; - }; - - rabbitTemplate.convertAndSend(DELAYED_EXCHANGE_NAME, DELAYED_ROUTING_KEY, messageWrapper, messagePostProcessor); + rabbitTemplate.convertAndSend(delayedExchangeName, delayedRoutingKey, messageWrapper, buildDelayedMessagePostProcessor(delayMillis)); - log.info("Published delayed tracking session: participants={}, delay={}ms", - messageWrapper.getPayload().getParticipantUserIds(), delayMillis); + log.info("Published delayed tracking session: participantCount={}, delay={}ms", + messageWrapper.getPayload().getParticipantUserIds().size(), delayMillis); } catch (AmqpException e) { - log.error("Failed to publish delayed tracking session: participants={}, delay={}ms", - messageWrapper.getPayload().getParticipantUserIds(),delayMillis); + log.error("Failed to publish delayed tracking session: participantCount={}, delay={}ms", + messageWrapper.getPayload().getParticipantUserIds().size(), delayMillis, e); + throw e; } } + private MessagePostProcessor buildDelayedMessagePostProcessor(long delayMillis) { + return message -> { + message.getMessageProperties().setHeader("x-delay", delayMillis); + return message; + }; + } + } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingDataService.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingDataService.java index 3562883..5bf014a 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingDataService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingDataService.java @@ -32,8 +32,7 @@ public void saveSessionAttendanceData(List volunteerIds, Long teamId) { long presentCount = records.stream() .map(this::parseRecordToBoolean) - .filter(Optional::isPresent) - .map(Optional::get) + .flatMap(Optional::stream) .filter(Boolean::booleanValue) .count(); @@ -48,11 +47,9 @@ public void saveSessionAttendanceData(List volunteerIds, Long teamId) { if (!updateList.isEmpty()) participantRepository.saveAll(updateList); // DB 저장 후 Redis 삭제 - for (Long volunteerId : volunteerIds) { - rabbitMQRedisService.clearAttendanceHistory(volunteerId); - } + volunteerIds.forEach(rabbitMQRedisService::clearAttendanceHistory); - log.info("참여자 출석 상태 {}건 저장 완료 (teamId={})", updateList.size(), teamId); + log.debug("참여자 출석 상태 {}건 저장 완료 (teamId={})", updateList.size(), teamId); } private Optional parseRecordToBoolean(String record) { @@ -62,7 +59,10 @@ private Optional parseRecordToBoolean(String record) { return switch (record) { case "1" -> Optional.of(true); case "0" -> Optional.of(false); - default -> Optional.empty(); + default -> { + log.warn("Unknown attendance record value: {}", record); + yield Optional.empty(); + } }; } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingService.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingService.java index 165c103..19e1b45 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingService.java @@ -9,6 +9,7 @@ import com.example.emergencyassistb4b4.domain.volunteer.enums.CheckinStatus; import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerTeamRepository; import com.example.emergencyassistb4b4.global.exception.ApiException; +import com.example.emergencyassistb4b4.global.status.ErrorStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -32,7 +33,7 @@ public class TrackingService { */ public void scheduleTrackingForTeam(Long teamId) { VolunteerTeam team = volunteerTeamRepository.findWithPostAndDetailsById(teamId) - .orElseThrow(); + .orElseThrow(()->new ApiException(ErrorStatus.TEAM_NOT_FOUND)); Post post = team.getPost(); VolunteerLocation location = post.getLocation(); @@ -50,12 +51,8 @@ public void scheduleTrackingForTeam(Long teamId) { .map(VolunteerParticipant::getId) .toList(); - for (VolunteerParticipant participant : participants) { - trackingSocketHandler.cacheVolunteerUserMapping( - participant.getId(), // 자원봉사자 ID - participant.getUser().getId() // 유저 ID - ); - } + participants.forEach(p -> trackingSocketHandler.cacheVolunteerUserMapping(p.getId(), p.getUser().getId())); + TrackingSessionDto sessionDto = TrackingSessionDto.from(team, location, policy, participantUserIds); diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisScheduler.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisScheduler.java index d072409..3621316 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisScheduler.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisScheduler.java @@ -16,14 +16,11 @@ public class RabbitMQRedisScheduler { @Scheduled(cron = "0 * * * * *") public void ScheduledRun(){ rabbitMQRedisService.getAllTrackingStates().forEach(attendanceEventListener::onAttendanceStateChanged); - - } @Scheduled(cron = "0 */5 * * * *") public void ScheduledRunDown(){ rabbitMQRedisService.getAllTrackingStates().forEach(attendanceEventListener::onAttendanceEnded); - } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisService.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisService.java index 264bf6c..08d0b13 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisService.java @@ -46,8 +46,6 @@ public List getAllTrackingStates() { .toList(); } - - public void clearTrackingState(Long teamId) { rabbitMQRedisRepository.deleteRabbitMQState(teamId); } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/controller/LocationTrackingWebSocketHandler.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/controller/LocationTrackingWebSocketHandler.java index d654f2d..5bbbc94 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/controller/LocationTrackingWebSocketHandler.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/controller/LocationTrackingWebSocketHandler.java @@ -5,6 +5,7 @@ import com.example.emergencyassistb4b4.domain.attendance.socket.service.LocationWebSocketService; import com.example.emergencyassistb4b4.domain.location.service.LocationService; import com.example.emergencyassistb4b4.domain.attendance.socket.notifier.TrackingNotifier; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,47 +29,65 @@ public void afterConnectionEstablished(WebSocketSession session) { @Override public void handleMessage(WebSocketSession session, WebSocketMessage message) { + if (!(message instanceof TextMessage textMessage)) { + log.warn("지원하지 않는 메시지 타입: {}", message.getClass()); + return; + } + try { - if (message instanceof TextMessage textMessage) { - String payload = textMessage.getPayload(); - // JSON -> LocationUpdateMessage 파싱 - WebSocketMessageWrapper wrapper = objectMapper.readValue(payload, WebSocketMessageWrapper.class); + processLocationMessage(textMessage.getPayload()); + } catch (Exception e) { + log.error("메시지 처리 중 오류 발생", e); + } + } - LocationUpdateMessage locMsg = wrapper.getData(); + private void processLocationMessage(String payload) { + WebSocketMessageWrapper wrapper; + try { + wrapper = objectMapper.readValue(payload, WebSocketMessageWrapper.class); + } catch (JsonProcessingException e) { + log.warn("JSON 파싱 실패, payload={}", payload, e); + return; + } - Long volunteerId = locMsg.getVolunteerId(); - double lat = locMsg.getLatitude(); - double lon = locMsg.getLongitude(); + LocationUpdateMessage locMsg = wrapper.getData(); + if (locMsg == null) { + log.warn("빈 데이터 수신, payload={}", payload); + return; + } - log.debug("Received location update from volunteerId={} lat={} lon={}", volunteerId, lat, lon); + Long volunteerId = locMsg.getVolunteerId(); + double lat = locMsg.getLatitude(); + double lon = locMsg.getLongitude(); - // 1. Redis GEO 저장 - locationService.saveCoordinates(volunteerId, lat, lon); + if (volunteerId == null || !isValidCoordinates(lat, lon)) { + log.warn("비정상 데이터 수신, volunteerId={}, lat={}, lon={}", volunteerId, lat, lon); + return; + } - // 2. 출석 체크 수행 - boolean isPresent = locationWebSocketService.checkAttendanceForVolunteer(volunteerId, lat, lon); + log.debug("Received location update from volunteerId={} lat={} lon={}", volunteerId, lat, lon); - // 3. 출석 상태 웹소켓으로 알림 전송 - trackingNotifier.notifyTrackingCheck(volunteerId, isPresent); + // 1. Redis GEO 저장 + locationService.saveCoordinates(volunteerId, lat, lon); - // 4. redis 에 저장 및 publish - locationWebSocketService.saveAndPublishAttendance(volunteerId, isPresent); - } else { - log.warn("지원하지 않는 메시지 타입: {}", message.getClass()); - } - } catch (Exception e) { - log.error("메시지 처리 중 오류 발생", e); - } + // 2. 출석 체크 수행 + boolean isPresent = locationWebSocketService.checkAttendanceForVolunteer(volunteerId, lat, lon); + + // 3. 출석 상태 웹소켓 알림 전송 + trackingNotifier.notifyTrackingCheck(volunteerId, isPresent); + + // 4. Redis 저장 및 publish + locationWebSocketService.saveAndPublishAttendance(volunteerId, isPresent); + } + + private boolean isValidCoordinates(double lat, double lon) { + return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; } @Override public void handleTransportError(WebSocketSession session, Throwable exception) { log.error("전송 오류 발생", exception); - try { - if (session.isOpen()) session.close(CloseStatus.SERVER_ERROR); - } catch (Exception e) { - log.error("세션 닫기 실패", e); - } + closeSessionSilently(session); } @Override @@ -80,4 +99,12 @@ public void afterConnectionClosed(WebSocketSession session, CloseStatus status) public boolean supportsPartialMessages() { return false; } + + private void closeSessionSilently(WebSocketSession session) { + try { + if (session.isOpen()) session.close(CloseStatus.SERVER_ERROR); + } catch (Exception e) { + log.error("세션 닫기 실패", e); + } + } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/TrackingSocketHandler.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/TrackingSocketHandler.java index 6c7ddfc..b5082cc 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/TrackingSocketHandler.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/TrackingSocketHandler.java @@ -13,11 +13,11 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; - @Slf4j @Component @RequiredArgsConstructor public class TrackingSocketHandler implements WebSocketHandler { + private final RabbitMQRedisService rabbitMQRedisService; private final LocationWebSocketService locationWebSocketService; private final Map> userSessions = new ConcurrentHashMap<>(); @@ -30,10 +30,6 @@ public void afterConnectionEstablished(WebSocketSession session) { closeSession(session, CloseStatus.NOT_ACCEPTABLE); return; } - registerSession(userId, session); - } - - private void registerSession(Long userId, WebSocketSession session) { userSessions.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(session); } @@ -73,7 +69,7 @@ private void removeSession(WebSocketSession session) { userSessions.entrySet().removeIf(entry -> entry.getValue().isEmpty()); } - // ============= Redis 캐싱 ============= + // ================== Redis 캐싱 ================== public void cacheVolunteerUserMapping(Long volunteerId, Long userId) { rabbitMQRedisService.mapVolunteerToUser(volunteerId, userId); } @@ -86,34 +82,30 @@ public void removeVolunteerUserMapping(Long volunteerParticipantId) { rabbitMQRedisService.unmapVolunteerFromUser(volunteerParticipantId); } - // ============= WebSocket 메시지 전송 ============= + // ================== WebSocket 메시지 전송 ================== @Transactional(readOnly = true) public void sendToUser(Long volunteerId, String event, Object payload) { - try { - Long userId = getUserIdByVolunteerId(volunteerId); - if (userId == null) { - log.warn("유저 매핑 없음: volunteerId={}", volunteerId); - locationWebSocketService.saveAndPublishAttendance(volunteerId, false); - return; - } - - WebSocketSession session = null; - Set sessions = userSessions.get(userId); - if (sessions != null && !sessions.isEmpty()) { - session = sessions.iterator().next(); // 단일 세션 선택 - } - - if (session == null || !session.isOpen()) { - log.warn("웹소켓 세션 없음 또는 닫힘: volunteerId={}, userId={}", volunteerId, userId); - locationWebSocketService.saveAndPublishAttendance(volunteerId, false); - removeVolunteerUserMapping(volunteerId); - return; - } + Long userId = getUserIdByVolunteerId(volunteerId); + if (userId == null) { + log.warn("유저 매핑 없음: volunteerId={}", volunteerId); + locationWebSocketService.saveAndPublishAttendance(volunteerId, false); + return; + } + Set sessions = userSessions.get(userId); + WebSocketSession session = (sessions != null && !sessions.isEmpty()) ? sessions.iterator().next() : null; + + if (session == null || !session.isOpen()) { + log.warn("웹소켓 세션 없음 또는 닫힘: volunteerId={}, userId={}", volunteerId, userId); + locationWebSocketService.saveAndPublishAttendance(volunteerId, false); + removeVolunteerUserMapping(volunteerId); + return; + } + + try { String json = objectMapper.writeValueAsString(Map.of("type", event, "data", payload)); session.sendMessage(new TextMessage(json)); log.debug("웹소켓 전송 성공: volunteerId={}, userId={}, event={}", volunteerId, userId, event); - } catch (Exception e) { log.error("웹소켓 전송 실패: volunteerId={}, event={}", volunteerId, event, e); locationWebSocketService.saveAndPublishAttendance(volunteerId, false); diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationWebSocketService.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationWebSocketService.java index 06b4d27..13ceba7 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationWebSocketService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationWebSocketService.java @@ -19,15 +19,17 @@ @RequiredArgsConstructor public class LocationWebSocketService { - private final VolunteerParticipantRepository volunteerParticipantRepository; + private final VolunteerParticipantRepository participantRepository; private final RabbitMQRedisService rabbitMQRedisService; private static final int DEFAULT_RADIUS_METERS = 50; - private static final int DEFAULT_TTL_MINUTES = 3; // 여유 시간 + private static final int DEFAULT_TTL_MINUTES = 3; + /** + * 해당 자원봉사자가 팀 위치 반경 내에 있는지 체크 + */ public boolean checkAttendanceForVolunteer(Long volunteerId, double lat, double lon) { - // 1 팀 ID 조회/캐싱 - VolunteerParticipant participant = volunteerParticipantRepository + VolunteerParticipant participant = participantRepository .findWithTeamAndPolicyById(volunteerId) .orElseThrow(() -> new ApiException(VOLUNTEER_NOT_FOUND)); @@ -37,58 +39,58 @@ public boolean checkAttendanceForVolunteer(Long volunteerId, double lat, double rabbitMQRedisService.mapVolunteerToTeam(volunteerId, teamId); log.debug("Cached teamId={} for volunteerId={}", teamId, volunteerId); } - // 2 팀 위치 확인/캐싱 - if (!rabbitMQRedisService.locationExists(teamId)) { - VolunteerTeam team = participant.getVolunteerTeam(); - Post post = team.getPost(); - VolunteerLocation location = post.getLocation(); - AttendancePolicy policy = post.getAttendancePolicy(); - - if (location == null || policy == null) { - throw new ApiException(ATTENDANCE_LOCATION_OR_POLICY_MISSING); - } - - // TTL = 세션 종료 시간까지 + 여유 3분 - Duration ttl = Duration.between(LocalDateTime.now(), policy.getCheckinEnd()).plusMinutes(DEFAULT_TTL_MINUTES); - if (ttl.isNegative() || ttl.isZero()) ttl = Duration.ofMinutes(DEFAULT_TTL_MINUTES); - - rabbitMQRedisService.updateTeamLocation(teamId, location.getLocationLat(), location.getLocationLng(), ttl); - log.debug("Cached geo center for teamId={} at lat={}, lon={}, ttl={}s", teamId, location.getLocationLat(), location.getLocationLng(), ttl.getSeconds()); - } - // 3 반경 체크 + cacheTeamLocationIfAbsent(participant, teamId); + int radius = participant.getVolunteerTeam().getPost().getAttendancePolicy() != null ? participant.getVolunteerTeam().getPost().getAttendancePolicy().getAttendanceRadiusMeters() : DEFAULT_RADIUS_METERS; boolean withinRadius = rabbitMQRedisService.isWithinRadius(teamId, lat, lon, radius); - log.debug("Geo check for teamId={} with lat={}, lon={}, radius={}m: {}", teamId, lat, lon, radius, withinRadius); + log.debug("Geo check for teamId={} lat={}, lon={}, radius={}m: {}", teamId, lat, lon, radius, withinRadius); return withinRadius; } + private void cacheTeamLocationIfAbsent(VolunteerParticipant participant, Long teamId) { + if (rabbitMQRedisService.locationExists(teamId)) return; + + VolunteerTeam team = participant.getVolunteerTeam(); + Post post = team.getPost(); + VolunteerLocation location = post.getLocation(); + AttendancePolicy policy = post.getAttendancePolicy(); + + if (location == null || policy == null) { + throw new ApiException(ATTENDANCE_LOCATION_OR_POLICY_MISSING); + } + + Duration ttl = Duration.between(LocalDateTime.now(), policy.getCheckinEnd()) + .plusMinutes(DEFAULT_TTL_MINUTES); + if (ttl.isNegative() || ttl.isZero()) ttl = Duration.ofMinutes(DEFAULT_TTL_MINUTES); + + rabbitMQRedisService.updateTeamLocation(teamId, location.getLocationLat(), location.getLocationLng(), ttl); + log.debug("Cached geo center for teamId={} at lat={}, lon={}, ttl={}s", teamId, location.getLocationLat(), location.getLocationLng(), ttl.getSeconds()); + } + + /** + * Redis에 출석 기록 저장 및 publish + */ public void saveAndPublishAttendance(Long volunteerId, boolean isPresent) { - VolunteerParticipant participant = volunteerParticipantRepository + VolunteerParticipant participant = participantRepository .findWithTeamAndPolicyById(volunteerId) .orElseThrow(() -> new ApiException(VOLUNTEER_NOT_FOUND)); AttendancePolicy policy = participant.getVolunteerTeam().getPost().getAttendancePolicy(); - - // ENDED 이벤트 처리 시, 현재 시점과 sessionEnd 중 더 늦은 시간을 기준 LocalDateTime now = LocalDateTime.now(); LocalDateTime sessionEnd = (policy != null && policy.getCheckinEnd() != null) ? policy.getCheckinEnd() : now; - // TTL 계산: sessionEnd 이후 DEFAULT_TTL_MINUTES까지 - LocalDateTime effectiveEnd = sessionEnd.isAfter(now) ? sessionEnd : now; - Duration ttl = Duration.between(now, effectiveEnd).plusMinutes(DEFAULT_TTL_MINUTES); - - // 음수 방어 + Duration ttl = Duration.between(now, sessionEnd.isAfter(now) ? sessionEnd : now) + .plusMinutes(DEFAULT_TTL_MINUTES); if (ttl.isNegative() || ttl.isZero()) ttl = Duration.ofMinutes(DEFAULT_TTL_MINUTES); rabbitMQRedisService.recordAttendance(volunteerId, isPresent, ttl); - log.info("Saved attendance for volunteerId={}, isPresent={}, ttl={}s", volunteerId, isPresent, ttl.getSeconds()); - + log.info("Saved attendance: volunteerId={}, isPresent={}, ttl={}s", volunteerId, isPresent, ttl.getSeconds()); } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerJoinService.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerJoinService.java index b31f001..30f0027 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerJoinService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerJoinService.java @@ -1,5 +1,6 @@ package com.example.emergencyassistb4b4.domain.volunteer.service; +import com.example.emergencyassistb4b4.domain.volunteer.domain.Post; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerParticipant; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerTeam; import com.example.emergencyassistb4b4.domain.volunteer.dto.Join.CheckinPeriodDto; @@ -7,6 +8,7 @@ import com.example.emergencyassistb4b4.domain.volunteer.dto.Join.VolunteerParticipationResponse; import com.example.emergencyassistb4b4.domain.volunteer.enums.CheckinStatus; import com.example.emergencyassistb4b4.domain.volunteer.infra.redis.service.TeamParticipationCleanupScheduler; +import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; import com.example.emergencyassistb4b4.domain.volunteer.infra.redis.service.TeamParticipationRedisService; import com.example.emergencyassistb4b4.domain.volunteer.repository.PostRepository; import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerParticipantRepository; diff --git a/src/main/java/com/example/emergencyassistb4b4/global/status/ErrorStatus.java b/src/main/java/com/example/emergencyassistb4b4/global/status/ErrorStatus.java index 0dd7e59..1e918b0 100644 --- a/src/main/java/com/example/emergencyassistb4b4/global/status/ErrorStatus.java +++ b/src/main/java/com/example/emergencyassistb4b4/global/status/ErrorStatus.java @@ -53,6 +53,7 @@ public enum ErrorStatus implements BaseErrorCode { VOLUNTEER_BAD_REQUEST(HttpStatus.BAD_REQUEST, "VO000", "VOLUNTEER_BAD_REQUEST"), VOLUNTEER_NOT_FOUND(HttpStatus.NOT_FOUND, "VO004", "VOLUNTEER_NOT_FOUND"), VOLUNTEER_FORBIDDEN(HttpStatus.FORBIDDEN, "VO0003", "VOLUNTEER_FORBIDDEN"), + TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "VO004", "TEAM_NOT_FOUND"), // 신고 REPORT_BAD_REQUEST(HttpStatus.BAD_REQUEST, "RP004", "유효하지 않은 값입니다"), From b750fbdb09d5e96ebae7366d6c1f2e403b814565 Mon Sep 17 00:00:00 2001 From: rabitis99 Date: Sat, 13 Sep 2025 17:36:19 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Volunteer=20=EB=AA=A8=EB=93=88=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=20Repository=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DTO 개선: VolunteerParticipationResponse, CheckinPeriodDto 구조 정리 및 불필요 필드 제거 - Controller 개선: updatePost, joinTeam 등에서 DB 중복 조회 제거, userDetails.getUser() 단일 조회 적용 - Service 개선: VolunteerJoinService, VolunteerPostService 리팩토링, Redis/DB 연계 로직 및 expired 처리 구조 개선 - Repository 정리: 사용되지 않는 메서드 삭제 - 기능 변경 없이 코드 간결화 및 유지보수성 향상 --- .../VolunteerAttendanceController.java | 23 +-- .../controller/VolunteerJoinController.java | 18 +- .../controller/VolunteerPostController.java | 32 ++-- .../volunteer/domain/AttendancePolicy.java | 2 +- .../domain/volunteer/domain/Post.java | 18 -- .../volunteer/domain/VolunteerLocation.java | 19 +- .../domain/VolunteerParticipant.java | 1 - .../volunteer/dto/Post/CreatePostRequest.java | 2 - .../volunteer/dto/Post/PostsResponse.java | 42 ----- .../volunteer/dto/Post/UserInfoResponse.java | 27 --- .../volunteer/repository/PostRepository.java | 3 - .../VolunteerParticipantRepository.java | 10 +- .../VolunteerParticipantRepositoryCustom.java | 1 - .../service/VolunteerJoinService.java | 60 ++++--- .../service/VolunteerParticipantService.java | 15 +- .../service/VolunteerPostService.java | 162 +++++++----------- 16 files changed, 135 insertions(+), 300 deletions(-) delete mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/PostsResponse.java delete mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UserInfoResponse.java diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerAttendanceController.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerAttendanceController.java index 9e4c56f..2268d85 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerAttendanceController.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerAttendanceController.java @@ -1,18 +1,20 @@ +// ============================== +// VolunteerAttendanceController +// ============================== package com.example.emergencyassistb4b4.domain.volunteer.controller; import com.example.emergencyassistb4b4.domain.volunteer.dto.Join.CheckinStatusRequest; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.TeamParticipantsResponse; import com.example.emergencyassistb4b4.domain.volunteer.service.VolunteerPostService; import com.example.emergencyassistb4b4.global.response.ApiResponse; -import com.example.emergencyassistb4b4.global.security.auth.CustomUserDetails; import com.example.emergencyassistb4b4.global.status.SuccessStatus; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@PreAuthorize("hasRole('NGO')") @RestController @RequiredArgsConstructor @RequestMapping("posts/{postId}/teams/{teamId}") @@ -20,34 +22,23 @@ public class VolunteerAttendanceController { private final VolunteerPostService volunteerPostService; - /** - * 특정 팀 참여자 리스트 조회 - */ - @PreAuthorize("hasRole('NGO')") @GetMapping public ResponseEntity> getTeamList( - @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long postId, @PathVariable Long teamId ) { - TeamParticipantsResponse teamParticipantsResponse = volunteerPostService.getTeamParticipants(postId, teamId); - return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_INFORMATION_SUCCESS, teamParticipantsResponse); + TeamParticipantsResponse response = volunteerPostService.getTeamParticipants(postId, teamId); + return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_INFORMATION_SUCCESS, response); } - /** - * 결석 처리된 참여자 출석 상태 변경 - */ - @PreAuthorize("hasRole('NGO')") @PatchMapping("volunteer-participants/{participantId}") public ResponseEntity> patchAttendance( - @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long postId, @PathVariable Long teamId, @PathVariable Long participantId, @Valid @RequestBody CheckinStatusRequest checkinStatusRequest ) { - // service에서 상태 변경 - volunteerPostService.updateParticipantAttendance(postId, teamId, participantId,checkinStatusRequest); + volunteerPostService.updateParticipantAttendance(postId, teamId, participantId, checkinStatusRequest); return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_STATUS_SUCCESS, null); } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerJoinController.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerJoinController.java index 681218c..0dbec49 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerJoinController.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerJoinController.java @@ -1,3 +1,6 @@ +// ============================== +// VolunteerJoinController +// ============================== package com.example.emergencyassistb4b4.domain.volunteer.controller; import com.example.emergencyassistb4b4.domain.volunteer.enums.CheckinStatus; @@ -18,6 +21,7 @@ import java.time.LocalDateTime; import java.util.List; +@PreAuthorize("hasRole('IND')") @RestController @RequiredArgsConstructor @RequestMapping @@ -25,29 +29,26 @@ public class VolunteerJoinController { private final VolunteerJoinService volunteerJoinService; - @PreAuthorize("hasRole('IND')") @PostMapping("/posts/{postId}/teams/{teamNumber}/apply") public ResponseEntity> joinTeam( @PathVariable Long postId, @PathVariable int teamNumber, @AuthenticationPrincipal CustomUserDetails userDetails ) { - volunteerJoinService.joinTeam(postId, teamNumber, userDetails.getUser().getId()); + volunteerJoinService.joinTeam(postId, teamNumber, userDetails.getUser()); return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_CREATE_SUCCESS, null); } - @PreAuthorize("hasRole('IND')") @PatchMapping("/volunteer-participants/{participantId}") - public ResponseEntity> cancelJoin( + public ResponseEntity> cancelJoin( @PathVariable Long participantId, @Valid @RequestBody CheckinStatusRequest request, @AuthenticationPrincipal CustomUserDetails userDetails ) { - volunteerJoinService.cancelJoin(participantId, request, userDetails.getUser().getId()); + volunteerJoinService.cancelJoin(participantId, request, userDetails.getUser()); return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_SUCCESS, null); } - @PreAuthorize("hasRole('IND')") @GetMapping("/volunteer-participants/my") public ResponseEntity>> getMyParticipationList( @AuthenticationPrincipal CustomUserDetails userDetails, @@ -61,8 +62,7 @@ public ResponseEntity>> getMyPa } List list = - volunteerJoinService.getMyParticipation(userDetails.getUser().getId(),checkinStatus,startTime,endTime); + volunteerJoinService.getMyParticipation(userDetails.getUser().getId(), checkinStatus, startTime, endTime); return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_SUCCESS, list); } - -} \ No newline at end of file +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerPostController.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerPostController.java index 6cde836..64cf0b8 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerPostController.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/controller/VolunteerPostController.java @@ -1,3 +1,6 @@ +// ============================== +// VolunteerPostController +// ============================== package com.example.emergencyassistb4b4.domain.volunteer.controller; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.*; @@ -6,7 +9,6 @@ import com.example.emergencyassistb4b4.global.status.SuccessStatus; import com.example.emergencyassistb4b4.domain.volunteer.service.VolunteerPostService; import jakarta.validation.Valid; - import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -15,6 +17,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@PreAuthorize("hasRole('NGO')") @RestController @RequiredArgsConstructor @RequestMapping("/post") @@ -22,40 +25,37 @@ public class VolunteerPostController { private final VolunteerPostService volunteerPostService; - @PreAuthorize("hasRole('NGO')") @PostMapping public ResponseEntity> createPost( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody CreatePostRequest request) { - volunteerPostService.createPost(userDetails.getUser().getId(), request); + volunteerPostService.createPost(userDetails.getUser(), request); return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_CREATE_SUCCESS, null); } - @PreAuthorize("hasRole('NGO')") @PatchMapping("/{postId}") public ResponseEntity> updatePost( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long postId, @Valid @RequestBody UpdatePostRequest request) { - volunteerPostService.updatePost(userDetails.getUser().getId(), postId, request); + volunteerPostService.updatePost(userDetails.getUser(), postId, request); return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_SUCCESS, null); } @GetMapping public ResponseEntity>> getPosts( - @ModelAttribute PostFilterRequest filter, - Pageable pageable + @ModelAttribute PostFilterRequest filter, + Pageable pageable ) { Slice response = volunteerPostService.getPostList(filter, pageable); return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_SUCCESS, response); } - @PreAuthorize("hasRole('NGO')") @GetMapping("/my") public ResponseEntity>> getMyPosts( - @AuthenticationPrincipal CustomUserDetails userDetails, - @ModelAttribute PostFilterRequest filter, - Pageable pageable + @AuthenticationPrincipal CustomUserDetails userDetails, + @ModelAttribute PostFilterRequest filter, + Pageable pageable ) { Slice response = volunteerPostService.getMyPostList(userDetails.getUser().getId(), filter, pageable); @@ -74,14 +74,12 @@ public ResponseEntity> getTeamStatus(@PathVariabl return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_SUCCESS, response); } - @PreAuthorize("hasRole('NGO')") @DeleteMapping("/{postId}") public ResponseEntity> deleteMyPost( - @PathVariable Long postId, - @AuthenticationPrincipal CustomUserDetails userDetails + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - volunteerPostService.deleteMyPost(userDetails.getUser().getId(), postId); // 소유자 검증 + 삭제 + volunteerPostService.deleteMyPost(userDetails.getUser().getId(), postId); return ApiResponse.onSuccess(SuccessStatus.VOLUNTEER_SUCCESS, null); } - -} \ No newline at end of file +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/AttendancePolicy.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/AttendancePolicy.java index d86a02f..824b466 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/AttendancePolicy.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/AttendancePolicy.java @@ -27,7 +27,7 @@ public class AttendancePolicy extends BaseEntity { private int attendanceRadiusMeters; @OneToOne - @JoinColumn(name = "post_id", nullable = false) + @JoinColumn(name = "post_id", nullable = false, unique = true) private Post post; public void setPost(Post post) { diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/Post.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/Post.java index 6a6ddc2..af90c82 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/Post.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/Post.java @@ -11,13 +11,9 @@ import java.time.LocalDate; import java.time.LocalTime; import lombok.*; - import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; - - -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -113,20 +109,6 @@ public void update(UpdatePostRequest request) { ); } - public void updateLocation(String province, String city, String placeName, Double latitude, Double longitude) { - if (this.location == null) { - this.location = new VolunteerLocation(); - } - this.location.update(province, city, placeName, latitude, longitude); - } - - public void updateAttendancePolicy(LocalDateTime start, LocalDateTime end, int radius) { - if (this.attendancePolicy == null) { - this.attendancePolicy = new AttendancePolicy(); - } - this.attendancePolicy.update(start, end, radius); - } - public void addTeams(List teamList) { this.teams = teamList; for (VolunteerTeam team : teamList) { diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/VolunteerLocation.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/VolunteerLocation.java index 3ce9068..16895a6 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/VolunteerLocation.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/VolunteerLocation.java @@ -2,18 +2,14 @@ import com.example.emergencyassistb4b4.global.entity.BaseEntity; import jakarta.persistence.*; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Getter +@Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor -@Builder +@Table(name = "volunteer_location") public class VolunteerLocation extends BaseEntity { @Id @@ -21,27 +17,24 @@ public class VolunteerLocation extends BaseEntity { private Long id; // 행정구역 (시/도) - @Column(name = "province", nullable = false, length = 255) + @Column(nullable = false, length = 255) private String province; // 행정구역 (구/군) - @Column(name = "city", length = 255) private String city; // 상세 주소 - @Column(name = "place_name", nullable = false) + @Column(nullable = false) private String placeName; // 위도 - @Column(name = "location_lat") private Double locationLat; // 경도 - @Column(name = "location_lng") private Double locationLng; @OneToOne - @JoinColumn(name = "post_id", nullable = false) + @JoinColumn(name = "post_id", nullable = false, unique = true) private Post post; public void setPost(Post post) { diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/VolunteerParticipant.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/VolunteerParticipant.java index 053535b..11ab101 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/VolunteerParticipant.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/VolunteerParticipant.java @@ -49,7 +49,6 @@ public class VolunteerParticipant { public void updateStatus(CheckinStatus newStatus) { if (this.checkinStatus == CheckinStatus.BLACKLISTED) { throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); - //IllegalStateException("블랙리스트는 상태 변경이 불가능합니다."); } this.checkinStatus = newStatus; } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/CreatePostRequest.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/CreatePostRequest.java index 727d035..03408f0 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/CreatePostRequest.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/CreatePostRequest.java @@ -7,8 +7,6 @@ import com.example.emergencyassistb4b4.domain.volunteer.dto.validator.ValidAttendancePolicy; import com.example.emergencyassistb4b4.domain.volunteer.enums.PostCategory; import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/PostsResponse.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/PostsResponse.java deleted file mode 100644 index 08b2029..0000000 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/PostsResponse.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.emergencyassistb4b4.domain.volunteer.dto.Post; - -import com.example.emergencyassistb4b4.domain.volunteer.domain.Post; -import com.example.emergencyassistb4b4.domain.volunteer.enums.PostCategory; -import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; -import java.time.LocalDate; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PostsResponse { - private Long id; - private String title; - private LocalDate volunteerDate; - private String province; - private String city; - private PostCategory category; - private int totalCapacity; - private LocalDate recruitmentStartDate; - private LocalDate recruitmentEndDate; - private PostStatus status; - - public static PostsResponse from(Post post) { - return PostsResponse.builder() - .id(post.getId()) - .title(post.getTitle()) - .volunteerDate(post.getVolunteerDate()) - .province(post.getLocation().getProvince()) - .city(post.getLocation().getCity()) - .category(post.getCategory()) - .totalCapacity(post.getTotalCapacity()) - .recruitmentStartDate(post.getRecruitmentStartDate()) - .recruitmentEndDate(post.getRecruitmentEndDate()) - .status(post.getStatus()) - .build(); - } -} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UserInfoResponse.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UserInfoResponse.java deleted file mode 100644 index 0b5747e..0000000 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UserInfoResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.emergencyassistb4b4.domain.volunteer.dto.Post; - -import com.example.emergencyassistb4b4.domain.user.domain.User; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class UserInfoResponse { - private Long id; - private String name; - private String email; - private String phone; - - public static UserInfoResponse from(User user) { - return UserInfoResponse.builder() - .id(user.getId()) - .name(user.getNickname()) - .email(user.getEmail()) - .phone(user.getPhoneNumber()) - .build(); - } -} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java index 91ee9f5..b825cc9 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java @@ -9,7 +9,6 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; public interface PostRepository extends JpaRepository, PostQueryRepository { @@ -48,8 +47,6 @@ boolean existsOverlappingCheckinPeriod(@Param("userId") Long userId, @Param("checkinStart") LocalDateTime checkinStart, @Param("checkinEnd") LocalDateTime checkinEnd); - Optional findByIdAndUserId(Long postId, Long userId); - @Query("SELECT t FROM Post p JOIN p.teams t WHERE p.id = :postId AND t.id = :teamId") Optional findTeamByPostIdAndTeamId(@Param("postId") Long postId, @Param("teamId") Long teamId); diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepository.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepository.java index 09eed7d..6f74f0c 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepository.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepository.java @@ -19,14 +19,6 @@ public interface VolunteerParticipantRepository extends JpaRepository findUserIdsByPostId(@Param("postId") Long postId); - @Query(""" - SELECT t.post.id - FROM VolunteerParticipant vp - JOIN vp.volunteerTeam t - WHERE vp.id = :participantId - """) - Optional findPostIdByParticipantId(@Param("participantId") Long participantId); - @Query(""" SELECT vp FROM VolunteerParticipant vp @@ -48,7 +40,7 @@ SELECT COUNT(vp) > 0 boolean existsActiveParticipation(@Param("userId") Long userId, @Param("postId") Long postId); @Query(""" - select count(vp) + select count(vp) from VolunteerParticipant vp where vp.volunteerTeam.id = :teamId and vp.checkinStatus = 'PARTICIPATED' diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepositoryCustom.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepositoryCustom.java index bd1874b..af154b8 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepositoryCustom.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepositoryCustom.java @@ -2,7 +2,6 @@ import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerParticipant; import com.example.emergencyassistb4b4.domain.volunteer.enums.CheckinStatus; -import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerJoinService.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerJoinService.java index 30f0027..63ecf21 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerJoinService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerJoinService.java @@ -1,5 +1,6 @@ package com.example.emergencyassistb4b4.domain.volunteer.service; +import com.example.emergencyassistb4b4.domain.user.domain.User; import com.example.emergencyassistb4b4.domain.volunteer.domain.Post; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerParticipant; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerTeam; @@ -8,7 +9,6 @@ import com.example.emergencyassistb4b4.domain.volunteer.dto.Join.VolunteerParticipationResponse; import com.example.emergencyassistb4b4.domain.volunteer.enums.CheckinStatus; import com.example.emergencyassistb4b4.domain.volunteer.infra.redis.service.TeamParticipationCleanupScheduler; -import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; import com.example.emergencyassistb4b4.domain.volunteer.infra.redis.service.TeamParticipationRedisService; import com.example.emergencyassistb4b4.domain.volunteer.repository.PostRepository; import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerParticipantRepository; @@ -37,19 +37,23 @@ public class VolunteerJoinService { private final VolunteerParticipantRepositoryCustom volunteerParticipantRepositoryCustom; private final TeamParticipationCleanupScheduler cleanupScheduler; - @Transactional - public void joinTeam(Long postId, int teamNumber, Long userId) { + public void joinTeam(Long postId, int teamNumber, User user) { LocalDateTime now = LocalDateTime.now(); + Long userId= user.getId(); - // 이미 활동 중인지 확인 - if (participantRepository.existsActiveParticipation(userId,postId)) { - throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); - } + // 이미 참여 중인지 체크 + ensureNotAlreadyParticipating(userId, postId); + + // 팀 + post 조회 + VolunteerTeam team = teamRepository.findByPost_IdAndTeamNumber(postId, teamNumber) + .orElseThrow(() -> new ApiException(ErrorStatus.VOLUNTEER_NOT_FOUND)); + + Post post = team.getPost(); - // 체크인 기간 조회 - CheckinPeriodDto period = postRepository.findCheckinPeriodByPostId(postId) - .orElseThrow(() -> new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST)); + // 체크인 기간 가져오기 + CheckinPeriodDto period = new CheckinPeriodDto(post.getAttendancePolicy().getCheckinStart(), + post.getAttendancePolicy().getCheckinEnd()); if (now.isAfter(period.checkinStart().minusMinutes(5))) { throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); @@ -59,14 +63,11 @@ public void joinTeam(Long postId, int teamNumber, Long userId) { boolean isOverlapping = postRepository.existsOverlappingCheckinPeriod( userId, postId, period.checkinStart(), period.checkinEnd() ); + if (isOverlapping) { throw new ApiException(ErrorStatus.VOLUNTEER_CONFLICT); } - // 팀 조회 - VolunteerTeam team = teamRepository.findByPost_IdAndTeamNumber(postId, teamNumber) - .orElseThrow(() -> new ApiException(ErrorStatus.VOLUNTEER_NOT_FOUND)); - // Redis + DB 저장 처리 executeWithRetry( () -> teamParticipationRedisService.tryJoinTeam( @@ -78,30 +79,30 @@ public void joinTeam(Long postId, int teamNumber, Long userId) { ); // DB 저장 - participantService.joinSave(userId, team.getId()); + participantService.joinSave(user, team); cleanupScheduler.scheduleCleanup(postId, team.getId(), period.checkinEnd()); } @Transactional - public void cancelJoin(Long participantId, CheckinStatusRequest request, Long userId) { + public void cancelJoin(Long participantId, CheckinStatusRequest request, User user) { LocalDateTime now = LocalDateTime.now(); + Long userId= user.getId(); VolunteerParticipant participant = participantRepository.findByIdAndUserId(participantId, userId) .orElseThrow(() -> new ApiException(ErrorStatus.VOLUNTEER_FORBIDDEN)); - Long teamId = participant.getVolunteerTeam().getId(); - Long postId = participant.getVolunteerTeam().getPost().getId(); - - CheckinPeriodDto period = postRepository.findCheckinPeriodByPostId(postId) - .orElseThrow(() -> new ApiException(ErrorStatus.VOLUNTEER_NOT_FOUND)); + VolunteerTeam team = participant.getVolunteerTeam(); + Post post = team.getPost(); + CheckinPeriodDto period = new CheckinPeriodDto(post.getAttendancePolicy().getCheckinStart(), + post.getAttendancePolicy().getCheckinEnd()); if (now.isAfter(period.checkinStart())) { throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); } executeWithRetry( - () -> teamParticipationRedisService.cancelJoin(postId, teamId, userId, period.checkinEnd()), + () -> teamParticipationRedisService.cancelJoin(post.getId(), team.getId(), userId, period.checkinEnd()), null ); @@ -109,13 +110,23 @@ public void cancelJoin(Long participantId, CheckinStatusRequest request, Long us } @Transactional(readOnly = true) - public List getMyParticipation(Long userId, CheckinStatus status,LocalDateTime startTime, LocalDateTime endTime) { - List participants = volunteerParticipantRepositoryCustom.findAllByUserIdWithPostAndTeam(userId,status,startTime,endTime); + public List getMyParticipation(Long userId, CheckinStatus status, LocalDateTime startTime, LocalDateTime endTime) { + List participants = volunteerParticipantRepositoryCustom + .findAllByUserIdWithPostAndTeam(userId, status, startTime, endTime); return participants.stream() .map(VolunteerParticipationResponse::from) .toList(); } + /** + * 이미 참여 중인지 확인 + */ + private void ensureNotAlreadyParticipating(Long userId, Long postId) { + if (participantRepository.existsActiveParticipation(userId, postId)) { + throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); + } + } + /** * Redis 작업 재시도 + 필요 시 롤백 처리 */ @@ -131,7 +142,6 @@ private void executeWithRetry(Runnable action, Runnable rollbackAction) { log.error("Redis 작업 실패, 재시도 중: {}", attempt, e); attempt++; if (attempt >= maxAttempts) { - // 재시도 최대치 도달 if (rollbackAction != null) { try { rollbackAction.run(); diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerParticipantService.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerParticipantService.java index 07485c5..9308a4a 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerParticipantService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerParticipantService.java @@ -1,7 +1,5 @@ package com.example.emergencyassistb4b4.domain.volunteer.service; -import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.event.AttendanceEventListener; -import com.example.emergencyassistb4b4.domain.attendance.socket.handler.TrackingSocketHandler; import com.example.emergencyassistb4b4.global.exception.ApiException; import com.example.emergencyassistb4b4.global.status.ErrorStatus; import com.example.emergencyassistb4b4.domain.user.domain.User; @@ -25,20 +23,9 @@ public class VolunteerParticipantService { private final UserRepository userRepository; private final VolunteerTeamRepository teamRepository; private final VolunteerParticipantRepository participantRepository; - private final TrackingSocketHandler trackingSocketHandler; - private final AttendanceEventListener eventListener; @Transactional - public VolunteerParticipant joinSave(Long userId, Long teamId) { - // 유저 검증 - User user = userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorStatus.USER_NOT_FOUND)); - - // 팀 검증 - VolunteerTeam team = teamRepository.findById(teamId) - .orElseThrow(() -> new ApiException(ErrorStatus.VOLUNTEER_NOT_FOUND)); - - // 팀 - 유저 정보 생성 + public VolunteerParticipant joinSave(User user, VolunteerTeam team) { VolunteerParticipant participant = VolunteerParticipant.builder() .user(user) .volunteerTeam(team) diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java index f95f4ad..f6fb11b 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java @@ -5,25 +5,22 @@ import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerParticipant; import com.example.emergencyassistb4b4.domain.volunteer.dto.Join.CheckinStatusRequest; +import com.example.emergencyassistb4b4.domain.volunteer.dto.Join.TeamStatusDto; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.*; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common.AttendancePolicyProvider; -import com.example.emergencyassistb4b4.domain.volunteer.enums.CheckinStatus; import com.example.emergencyassistb4b4.domain.volunteer.infra.redis.service.TTLRedisService; import com.example.emergencyassistb4b4.domain.volunteer.infra.redis.service.TeamParticipationRedisService; import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; import com.example.emergencyassistb4b4.domain.volunteer.kafka.producer.VolunteerCancelEventProducer; -import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerParticipantRepository; +import com.example.emergencyassistb4b4.domain.volunteer.kafka.producer.VolunteerUpdatedEventProducer; +import com.example.emergencyassistb4b4.domain.volunteer.repository.PostRepository; import com.example.emergencyassistb4b4.global.exception.ApiException; import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerCancelEvent; import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerUpdatedEvent; import com.example.emergencyassistb4b4.global.status.ErrorStatus; import com.example.emergencyassistb4b4.domain.user.domain.User; -import com.example.emergencyassistb4b4.domain.user.repository.UserRepository; import com.example.emergencyassistb4b4.domain.volunteer.domain.Post; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerTeam; -import com.example.emergencyassistb4b4.domain.volunteer.dto.Join.TeamStatusDto; -import com.example.emergencyassistb4b4.domain.volunteer.kafka.producer.VolunteerUpdatedEventProducer; -import com.example.emergencyassistb4b4.domain.volunteer.repository.PostRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; @@ -34,14 +31,12 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import java.util.Optional; @Service @RequiredArgsConstructor @Slf4j public class VolunteerPostService { - private final UserRepository userRepository; private final PostRepository postRepository; private final TeamParticipationRedisService teamParticipationRedisService; private final VolunteerUpdatedEventProducer producer; @@ -50,90 +45,42 @@ public class VolunteerPostService { private final TTLRedisService ttlRedisService; private final RabbitMQRedisService rabbitMQRedisService; - // 모집 게시글 생성 @Transactional - public void createPost(Long userId, CreatePostRequest request) { - - // 유저 검증 - User user = userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorStatus.USER_NOT_FOUND)); - - // Post 생성 + public void createPost(User user, CreatePostRequest request) { Post post = request.toEntity(user); - - // 팀 생성 List teams = generateTeams(post, request.getTotalCapacity(), request.getTeamSize()); post.addTeams(teams); - - // 저장 postRepository.save(post); - scheduleAttendanceForTeams(teams, request.getAttendancePolicy()); - } - // 모집 게시글 수정 @Transactional - public void updatePost(Long userId, Long postId, UpdatePostRequest request) { - + public void updatePost(User user, Long postId, UpdatePostRequest request) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); - // 업데이트 post.update(request); // kafka 메세지 발행 VolunteerUpdatedEvent event = VolunteerUpdatedEvent.from(post); - scheduleAttendanceForTeams(post.getTeams(), request.getAttendancePolicy()); - producer.sendVolunteerUpdatedEvent(event); - } - // 모집 게시글 다건 조회 @Transactional(readOnly = true) public Slice getPostList(PostFilterRequest filter, Pageable pageable) { - - if (filter.getVolunteerStartDate() != null && filter.getVolunteerEndDate() != null && - filter.getVolunteerStartDate().isAfter(filter.getVolunteerEndDate())) { - throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); - } - + validateFilterDates(filter); Slice posts = postRepository.findPosts(null, filter, pageable); - - return posts.map(post -> { - int currentParticipants = post.getTeams().stream().mapToInt(team -> { - var period = postRepository.findCheckinPeriodByPostId(post.getId()).orElse(null); - boolean expired = period != null && LocalDateTime.now().isAfter(period.checkinEnd()); - return expired ? 0 : teamParticipationRedisService.getCurrentCount(post.getId(), team.getId()); - }).sum(); - return PostTotalResponse.from(post, currentParticipants); - }); + return getPostTotalResponses(posts); } - // 모집 게시글 다건 조회 (NGO) @Transactional(readOnly = true) public Slice getMyPostList(Long userId, PostFilterRequest filter, Pageable pageable) { - if (filter.getVolunteerStartDate() != null && filter.getVolunteerEndDate() != null && - filter.getVolunteerStartDate().isAfter(filter.getVolunteerEndDate())) { - throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); - } - + validateFilterDates(filter); Slice posts = postRepository.findPosts(userId, filter, pageable); - - return posts.map(post -> { - int currentParticipants = post.getTeams().stream().mapToInt(team -> { - var period = postRepository.findCheckinPeriodByPostId(post.getId()).orElse(null); - boolean expired = period != null && LocalDateTime.now().isAfter(period.checkinEnd()); - return expired ? 0 : teamParticipationRedisService.getCurrentCount(post.getId(), team.getId()); - }).sum(); - return PostTotalResponse.from(post, currentParticipants); - }); + return getPostTotalResponses(posts); } - - // 모집 게시글 조회 @Transactional(readOnly = true) public PostDetailResponse getPost(Long postId) { Post post = postRepository.findById(postId) @@ -150,15 +97,12 @@ public void deleteMyPost(Long userId, Long postId) { if (!post.getUser().getId().equals(userId)) { throw new ApiException(ErrorStatus.FORBIDDEN); } - if (post.getStatus() != PostStatus.OPEN) { throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); } VolunteerCancelEvent event = VolunteerCancelEvent.from(post); - try { - // Kafka 전송 성공해야 다음으로 진행 volunteerCancelEventProducer.sendVolunteerCanceledEvent(event); log.info("Kafka 발행 성공: {}", event); } catch (Exception e) { @@ -166,82 +110,96 @@ public void deleteMyPost(Long userId, Long postId) { throw new ApiException(ErrorStatus.KAFKA_SEND_FAILED); } - - for (VolunteerTeam volunteerTeam: post.getTeams()){ + post.getTeams().forEach(team -> { try { - rabbitMQRedisService.clearTrackingState(volunteerTeam.getId()); + rabbitMQRedisService.clearTrackingState(team.getId()); } catch (Exception e) { - log.error("TrackingState 삭제 실패 teamId={} : {}", volunteerTeam.getId(), e.getMessage()); - // 필요시 재시도 큐나 DLQ로 이동 + log.error("TrackingState 삭제 실패 teamId={} : {}", team.getId(), e.getMessage()); } - } + }); ttlRedisService.deleteAllKeysByPostId(postId); postRepository.delete(post); } - @Transactional(readOnly = true) - public TeamParticipantsResponse getTeamParticipants(Long postId, Long teamId){ - // Redis 또는 DB 기반으로 팀 참여자 상태 조회 - VolunteerTeam volunteerTeam = postRepository.findTeamByPostIdAndTeamId(postId, teamId). - orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); + public TeamParticipantsResponse getTeamParticipants(Long postId, Long teamId) { + VolunteerTeam volunteerTeam = postRepository.findTeamByPostIdAndTeamId(postId, teamId) + .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); - return TeamParticipantsResponse.fromEntities(volunteerTeam.getId(), volunteerTeam.getTeamNumber(),volunteerTeam.getParticipants()); + return TeamParticipantsResponse.fromEntities(volunteerTeam.getId(), + volunteerTeam.getTeamNumber(), + volunteerTeam.getParticipants()); } @Transactional - public void updateParticipantAttendance(Long postId, Long teamId, Long participantId, CheckinStatusRequest checkinStatusRequest){ - - VolunteerParticipant volunteerParticipant=postRepository.findParticipantInTeam(postId,teamId,participantId) - .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND));; + public void updateParticipantAttendance(Long postId, Long teamId, Long participantId, CheckinStatusRequest checkinStatusRequest) { + VolunteerParticipant volunteerParticipant = postRepository.findParticipantInTeam(postId, teamId, participantId) + .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); volunteerParticipant.updateStatus(checkinStatusRequest.getStatus()); - } - // 게시글 별 팀 인원 조회 @Transactional(readOnly = true) public PostTeamsResponse getTeamStatus(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorStatus.VOLUNTEER_NOT_FOUND)); - List teamStatuses = post.getTeams().stream().map(team -> { - var period = postRepository.findCheckinPeriodByPostId(postId).orElse(null); - boolean expired = period != null && LocalDateTime.now().isAfter(period.checkinEnd()); - int currentCount = expired ? 0 : teamParticipationRedisService.getCurrentCount(postId, team.getId()); - return new TeamStatusDto(team.getId(), team.getTeamNumber(), team.getMaxCapacity(), currentCount); - }).toList(); + var periodOpt = postRepository.findCheckinPeriodByPostId(postId); + LocalDateTime now = LocalDateTime.now(); + boolean expired = periodOpt.map(p -> now.isAfter(p.checkinEnd())).orElse(false); + + List teamStatuses = post.getTeams().stream() + .map(team -> new TeamStatusDto(team.getId(), + team.getTeamNumber(), + team.getMaxCapacity(), + expired ? 0 : teamParticipationRedisService.getCurrentCount(postId, team.getId()))) + .toList(); return new PostTeamsResponse(post.getId(), teamStatuses); } - // 팀 생성 + private Slice getPostTotalResponses(Slice posts) { + LocalDateTime now = LocalDateTime.now(); + + return posts.map(post -> { + var periodOpt = postRepository.findCheckinPeriodByPostId(post.getId()); + boolean expired = periodOpt.map(p -> now.isAfter(p.checkinEnd())).orElse(false); + + int currentParticipants = post.getTeams().stream() + .mapToInt(team -> expired ? 0 : teamParticipationRedisService.getCurrentCount(post.getId(), team.getId())) + .sum(); + + return PostTotalResponse.from(post, currentParticipants); + }); + } + + private void validateFilterDates(PostFilterRequest filter) { + if (filter.getVolunteerStartDate() != null && + filter.getVolunteerEndDate() != null && + filter.getVolunteerStartDate().isAfter(filter.getVolunteerEndDate())) { + throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); + } + } + private List generateTeams(Post post, int totalCapacity, int teamSize) { List volunteerTeams = new ArrayList<>(); int teamCount = totalCapacity / teamSize; for (int i = 0; i < teamCount; i++) { - VolunteerTeam team = VolunteerTeam.builder() + volunteerTeams.add(VolunteerTeam.builder() .post(post) - .teamNumber(i+1) + .teamNumber(i + 1) .maxCapacity(teamSize) - .build(); - volunteerTeams.add(team); + .build()); } return volunteerTeams; } - // 제네릭 메서드로 변경 - private void scheduleAttendanceForTeams( - List teams, - T request - ) { + private void scheduleAttendanceForTeams(List teams, T request) { LocalDateTime checkinStart = request.getAttendancePolicy().getCheckinStart(); - teams.stream() .map(team -> new AttendanceStateSetEvent(team.getId(), checkinStart)) .forEach(attendanceEventListener::onAttendanceStateSet); } - -} \ No newline at end of file +} From 377d3b6aaa7fd3feb841165f4049cc96d6af2cf4 Mon Sep 17 00:00:00 2001 From: rabitis99 Date: Sun, 14 Sep 2025 15:15:20 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20&=20=EB=AA=A8=EC=A7=91=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=ED=9B=84=20=EC=B0=B8=EA=B0=80=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/volunteer/domain/Post.java | 12 +++++++ .../domain/volunteer/enums/CheckinStatus.java | 6 +++- .../volunteer/repository/PostRepository.java | 4 +++ .../VolunteerParticipantRepository.java | 10 ++++++ .../VolunteerStatusUpdateScheduler.java | 24 +++++++++++++ .../service/VolunteerPostService.java | 36 +++++++++++++------ 6 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/volunteer/scheduler/VolunteerStatusUpdateScheduler.java diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/Post.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/Post.java index af90c82..6c3238e 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/Post.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/domain/Post.java @@ -79,6 +79,18 @@ public void setAttendancePolicy(AttendancePolicy policy) { policy.setPost(this); } + public void setStatus(PostStatus postStatus){ + this.status=postStatus; + } + + public boolean isOpen() { + return PostStatus.OPEN.equals(this.status); + } + + public boolean isNotOpen() { + return !isOpen(); + } + public void update(UpdatePostRequest request) { this.title = request.getTitle(); diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/enums/CheckinStatus.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/enums/CheckinStatus.java index 53f24a2..800b608 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/enums/CheckinStatus.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/enums/CheckinStatus.java @@ -5,5 +5,9 @@ public enum CheckinStatus { CANCELLED, BLACKLISTED, PRESENT, // 출석 - ABSENT + ABSENT; + + public boolean isParticipated() { + return this != CANCELLED && this != BLACKLISTED; + } } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java index b825cc9..c9d4020 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/PostRepository.java @@ -8,7 +8,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface PostRepository extends JpaRepository, PostQueryRepository { @@ -64,6 +66,8 @@ Optional findParticipantInTeam(@Param("postId") Long postI Optional findByIdWithTeams(@Param("postId") Long postId); + @Query("SELECT p FROM Post p WHERE p.recruitmentEndDate <= :today") + List findAllExpiredPosts(@Param("today") LocalDate today); } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepository.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepository.java index 6f74f0c..093ae0c 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepository.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/repository/VolunteerParticipantRepository.java @@ -46,4 +46,14 @@ select count(vp) and vp.checkinStatus = 'PARTICIPATED' """) long countParticipatedByTeamId(@Param("teamId") Long teamId); + + @Query(""" + select count(vp) + from VolunteerParticipant vp + where vp.volunteerTeam.id = :teamId + and vp.checkinStatus not in ('CANCELLED', 'BLACKLISTED') +""") + long countValidParticipatedByTeamId(@Param("teamId") Long teamId); + + } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/scheduler/VolunteerStatusUpdateScheduler.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/scheduler/VolunteerStatusUpdateScheduler.java new file mode 100644 index 0000000..7a2d6dd --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/scheduler/VolunteerStatusUpdateScheduler.java @@ -0,0 +1,24 @@ +package com.example.emergencyassistb4b4.domain.volunteer.scheduler; + +import com.example.emergencyassistb4b4.domain.volunteer.domain.Post; +import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; +import com.example.emergencyassistb4b4.domain.volunteer.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class VolunteerStatusUpdateScheduler { + private final PostRepository postRepository; + + @Scheduled(cron = "0 0 0 * * *") + public void updateVolunteerStatus() { + List posts = postRepository.findAllExpiredPosts(LocalDate.now()); + posts.forEach(post -> post.setStatus(PostStatus.CLOSED)); + postRepository.saveAll(posts); + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java index f6fb11b..1d16fdd 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/service/VolunteerPostService.java @@ -14,6 +14,7 @@ import com.example.emergencyassistb4b4.domain.volunteer.kafka.producer.VolunteerCancelEventProducer; import com.example.emergencyassistb4b4.domain.volunteer.kafka.producer.VolunteerUpdatedEventProducer; import com.example.emergencyassistb4b4.domain.volunteer.repository.PostRepository; +import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerParticipantRepository; import com.example.emergencyassistb4b4.global.exception.ApiException; import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerCancelEvent; import com.example.emergencyassistb4b4.global.kafka.dto.VolunteerUpdatedEvent; @@ -44,12 +45,14 @@ public class VolunteerPostService { private final VolunteerCancelEventProducer volunteerCancelEventProducer; private final TTLRedisService ttlRedisService; private final RabbitMQRedisService rabbitMQRedisService; + private final VolunteerParticipantRepository participantRepository; @Transactional public void createPost(User user, CreatePostRequest request) { Post post = request.toEntity(user); List teams = generateTeams(post, request.getTotalCapacity(), request.getTeamSize()); post.addTeams(teams); + postRepository.save(post); scheduleAttendanceForTeams(teams, request.getAttendancePolicy()); } @@ -61,7 +64,7 @@ public void updatePost(User user, Long postId, UpdatePostRequest request) { post.update(request); - // kafka 메세지 발행 + // Kafka 발행 VolunteerUpdatedEvent event = VolunteerUpdatedEvent.from(post); scheduleAttendanceForTeams(post.getTeams(), request.getAttendancePolicy()); producer.sendVolunteerUpdatedEvent(event); @@ -85,7 +88,6 @@ public Slice getMyPostList(Long userId, PostFilterRequest fil public PostDetailResponse getPost(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); - return PostDetailResponse.from(post); } @@ -97,7 +99,7 @@ public void deleteMyPost(Long userId, Long postId) { if (!post.getUser().getId().equals(userId)) { throw new ApiException(ErrorStatus.FORBIDDEN); } - if (post.getStatus() != PostStatus.OPEN) { + if (!post.isOpen()) { throw new ApiException(ErrorStatus.VOLUNTEER_BAD_REQUEST); } @@ -127,13 +129,16 @@ public TeamParticipantsResponse getTeamParticipants(Long postId, Long teamId) { VolunteerTeam volunteerTeam = postRepository.findTeamByPostIdAndTeamId(postId, teamId) .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); - return TeamParticipantsResponse.fromEntities(volunteerTeam.getId(), + return TeamParticipantsResponse.fromEntities( + volunteerTeam.getId(), volunteerTeam.getTeamNumber(), - volunteerTeam.getParticipants()); + volunteerTeam.getParticipants() + ); } @Transactional - public void updateParticipantAttendance(Long postId, Long teamId, Long participantId, CheckinStatusRequest checkinStatusRequest) { + public void updateParticipantAttendance(Long postId, Long teamId, Long participantId, + CheckinStatusRequest checkinStatusRequest) { VolunteerParticipant volunteerParticipant = postRepository.findParticipantInTeam(postId, teamId, participantId) .orElseThrow(() -> new ApiException(ErrorStatus.POST_NOT_FOUND)); @@ -147,27 +152,36 @@ public PostTeamsResponse getTeamStatus(Long postId) { var periodOpt = postRepository.findCheckinPeriodByPostId(postId); LocalDateTime now = LocalDateTime.now(); - boolean expired = periodOpt.map(p -> now.isAfter(p.checkinEnd())).orElse(false); + boolean checkinExpired = periodOpt.map(p -> now.isAfter(p.checkinEnd())).orElse(false); List teamStatuses = post.getTeams().stream() - .map(team -> new TeamStatusDto(team.getId(), + .map(team -> new TeamStatusDto( + team.getId(), team.getTeamNumber(), team.getMaxCapacity(), - expired ? 0 : teamParticipationRedisService.getCurrentCount(postId, team.getId()))) + resolveCurrentCount(post, team, checkinExpired) + )) .toList(); return new PostTeamsResponse(post.getId(), teamStatuses); } + private int resolveCurrentCount(Post post, VolunteerTeam team, boolean checkinExpired) { + if (checkinExpired || post.isNotOpen()) { + return (int) participantRepository.countValidParticipatedByTeamId(team.getId()); + } + return teamParticipationRedisService.getCurrentCount(post.getId(), team.getId()); + } + private Slice getPostTotalResponses(Slice posts) { LocalDateTime now = LocalDateTime.now(); return posts.map(post -> { var periodOpt = postRepository.findCheckinPeriodByPostId(post.getId()); - boolean expired = periodOpt.map(p -> now.isAfter(p.checkinEnd())).orElse(false); + boolean checkinExpired = periodOpt.map(p -> now.isAfter(p.checkinEnd())).orElse(false); int currentParticipants = post.getTeams().stream() - .mapToInt(team -> expired ? 0 : teamParticipationRedisService.getCurrentCount(post.getId(), team.getId())) + .mapToInt(team -> resolveCurrentCount(post, team, checkinExpired)) .sum(); return PostTotalResponse.from(post, currentParticipants); From 45031573dc896302f7e0e8662fdc59843bccd1a9 Mon Sep 17 00:00:00 2001 From: rabitis99 Date: Sat, 20 Sep 2025 18:20:59 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=EC=A3=BC=EC=84=9D=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rabbitmq/consumer/TrackingListener.java | 70 ++++++------ .../dto/IndividualTrackingSessionDto.java | 7 +- .../rabbitmq/dto/MessageWrapper.java | 2 + .../attendance/rabbitmq/dto/RabbitMQ.java | 12 +-- .../rabbitmq/dto/TrackingSessionDto.java | 16 ++- .../event/AttendanceEventListener.java | 46 ++++---- .../event/AttendanceStateSetEvent.java | 3 +- .../rabbitmq/event/TrackingScheduleEvent.java | 14 --- .../event/TrackingScheduleEventListener.java | 20 ---- .../publisher/TrackingSessionPublisher.java | 20 ++-- .../rabbitmq/service/TrackingDataService.java | 14 +-- .../rabbitmq/service/TrackingService.java | 37 +++---- .../rabbitmq/util/RabbitMqUtils.java | 1 + .../redis/RabbitMQRedisKeyUtil.java | 7 ++ .../redis/RabbitMQRedisRepository.java | 48 +++++---- .../redis/RabbitMQRedisScheduler.java | 19 ++-- .../redis/RabbitMQRedisService.java | 52 +++++---- .../LocationTrackingWebSocketHandler.java | 92 +++------------- .../socket/dto/LocationUpdateMessage.java | 5 + .../socket/dto/WebSocketMessageWrapper.java | 12 +-- .../socket/handler/TrackingSocketHandler.java | 102 +++++------------- .../handler/WebSocketMessageSender.java | 36 +++++++ .../handler/WebSocketSessionManager.java | 32 ++++++ .../message/AttendanceStatusMessage.java | 9 +- .../socket/message/TrackingMessage.java | 10 +- .../socket/notifier/TrackingNotifier.java | 24 ++--- .../service/LocationTrackingService.java | 60 +++++++++++ .../service/LocationWebSocketService.java | 86 ++++----------- .../socket/utils/LocationWebSocketUtils.java | 75 +++++++++++++ .../socket/utils/WebSocketUtils.java | 8 ++ .../controller/KakaoMapController.java | 9 +- .../controller/LocationController.java | 13 +-- .../dto/request/CoordinateRequestDto.java | 15 --- .../dto/request/RegionRequestDto.java | 1 + .../dto/response/DisasterReportMapper.java | 3 +- .../dto/response/DisasterReportSimpleDto.java | 4 + .../dto/response/DisasterSummaryDto.java | 6 +- .../dto/response/ShelterResponseDto.java | 2 +- .../location/redis/LocationRedisKeyUtil.java | 24 +++++ .../location/redis/LocationRepository.java | 52 ++------- .../location/service/LocationService.java | 8 -- .../location/service/ShelterService.java | 63 +++++++++++ .../global/config/rabbitMQ/RabbitConfig.java | 54 +++++----- .../config/rabbitMQ/RabbitMQConstant.java | 13 +++ .../schedulerConfig/SchedulerConfig.java | 8 ++ .../webSocket/JwtHandshakeInterceptor.java | 23 ++-- 46 files changed, 660 insertions(+), 577 deletions(-) delete mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/TrackingScheduleEvent.java delete mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/TrackingScheduleEventListener.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/WebSocketMessageSender.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/WebSocketSessionManager.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationTrackingService.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/utils/LocationWebSocketUtils.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/utils/WebSocketUtils.java delete mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/location/dto/request/CoordinateRequestDto.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/location/redis/LocationRedisKeyUtil.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/domain/location/service/ShelterService.java create mode 100644 src/main/java/com/example/emergencyassistb4b4/global/config/rabbitMQ/RabbitMQConstant.java diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/consumer/TrackingListener.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/consumer/TrackingListener.java index 4372062..2c9b246 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/consumer/TrackingListener.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/consumer/TrackingListener.java @@ -4,25 +4,25 @@ import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.SessionState; import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.TrackingSessionDto; import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.service.TrackingDataService; +import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; import com.example.emergencyassistb4b4.domain.attendance.socket.handler.TrackingSocketHandler; -import static com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.IndividualTrackingSessionDto.buildIndividualDto; -import static com.example.emergencyassistb4b4.domain.attendance.rabbitmq.util.RabbitMqUtils.isValidMessage; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.rabbitmq.client.Channel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.amqp.support.AmqpHeaders; import org.springframework.messaging.handler.annotation.Header; import org.springframework.stereotype.Component; -import com.rabbitmq.client.Channel; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.io.IOException; import java.util.List; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import static com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.IndividualTrackingSessionDto.buildIndividualDto; +import static com.example.emergencyassistb4b4.domain.attendance.rabbitmq.util.RabbitMqUtils.isValidMessage; + @Slf4j @Component @RequiredArgsConstructor @@ -30,54 +30,54 @@ public class TrackingListener { private final TrackingSocketHandler socketHandler; private final TrackingDataService trackingService; - private final ObjectMapper objectMapper; - private final ScheduledExecutorService scheduler= Executors.newSingleThreadScheduledExecutor(); + private final RabbitMQRedisService rabbitMQRedisService; + private final ScheduledExecutorService scheduler; - @RabbitListener(queues = "tracking-delay-queue", - containerFactory = "rabbitListenerContainerFactory") + // 메시지 수신 및 처리 + @RabbitListener(queues = "tracking-delay-queue", containerFactory = "rabbitListenerContainerFactory") public void onMessage(MessageWrapper message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException { - try { - String json = objectMapper.writeValueAsString(message); - log.debug("Received raw message from queue: {}", json); - if (!isValidMessage(message)) { - log.warn("잘못된 메시지를 수신했습니다: {}", message); - channel.basicAck(tag, false); // 잘못된 메시지라도 Ack 처리 + channel.basicAck(tag, false); return; } - SessionState state = message.getSessionState(); - TrackingSessionDto dto = message.getPayload(); - List participantIds = dto.getParticipantUserIds(); - - switch (state) { - case READY, STARTED -> sendTypedMessageToVolunteers(participantIds, state, dto); - case ENDED -> { - sendTypedMessageToVolunteers(participantIds, state, dto); - scheduler.schedule(() -> { - trackingService.saveSessionAttendanceData(participantIds, dto.getTeamId()); - participantIds.forEach(socketHandler::removeVolunteerUserMapping); - }, 1, TimeUnit.MINUTES); - } - default -> log.warn("알 수 없는 세션 상태 수신: {}", state); - } - - // 성공 처리 시 Ack + processMessage(message); channel.basicAck(tag, false); } catch (Exception e) { log.error("메시지 처리 실패, 재시도 예정: {}", message, e); - // 실패 시 Nack + requeue=true → 재시도 channel.basicNack(tag, false, true); } } - private void sendTypedMessageToVolunteers(List volunteerIds, SessionState state, TrackingSessionDto dto) { + // 메시지 상태에 따라 WebSocket 전송 및 종료 후 처리 + private void processMessage(MessageWrapper message) { + SessionState state = message.getSessionState(); + TrackingSessionDto dto = message.getPayload(); + List participantIds = dto.getParticipantUserIds(); + + switch (state) { + case READY, STARTED -> sendToVolunteers(participantIds, state, dto); + case ENDED -> { + sendToVolunteers(participantIds, state, dto); + scheduler.schedule(() -> endSessionProcessing(participantIds, dto.getTeamId()), 1, TimeUnit.MINUTES); // 종료 후 처리 예약 + } + default -> log.warn("알 수 없는 세션 상태 수신: {}", state); + } + } + + // 상태 기반 WebSocket 메시지 전송 + private void sendToVolunteers(List volunteerIds, SessionState state, TrackingSessionDto dto) { for (Long volunteerId : volunteerIds) { socketHandler.sendToUser(volunteerId, state.name(), buildIndividualDto(dto, volunteerId)); } } + // 세션 종료 후 출석 저장 및 Redis 매핑 해제 + private void endSessionProcessing(List participantIds, Long teamId) { + trackingService.saveSessionAttendanceData(participantIds, teamId); + participantIds.forEach(rabbitMQRedisService::unmapVolunteerFromUser); + } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/IndividualTrackingSessionDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/IndividualTrackingSessionDto.java index bcb99e2..72fe339 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/IndividualTrackingSessionDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/IndividualTrackingSessionDto.java @@ -14,6 +14,8 @@ @NoArgsConstructor public class IndividualTrackingSessionDto { + private Long postId; + private Long teamId; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") @@ -23,22 +25,23 @@ public class IndividualTrackingSessionDto { private LocalDateTime endTime; private double targetLat; + private double targetLng; private int meter; - private long intervalSeconds; private Long participantUserId; + // TrackingSessionDto에서 개인별 세션 DTO를 생성 public static IndividualTrackingSessionDto buildIndividualDto(TrackingSessionDto dto, Long volunteerId) { return IndividualTrackingSessionDto.builder() + .postId(dto.getPostId()) .teamId(dto.getTeamId()) .startTime(dto.getStartTime()) .endTime(dto.getEndTime()) .targetLat(dto.getTargetLat()) .targetLng(dto.getTargetLng()) .meter(dto.getMeter()) - .intervalSeconds(dto.getIntervalSeconds()) .participantUserId(volunteerId) .build(); } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/MessageWrapper.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/MessageWrapper.java index 40a4901..722386e 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/MessageWrapper.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/MessageWrapper.java @@ -8,6 +8,8 @@ @NoArgsConstructor @AllArgsConstructor public class MessageWrapper { + private SessionState sessionState; + private TrackingSessionDto payload; } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/RabbitMQ.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/RabbitMQ.java index 5344911..e861a7a 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/RabbitMQ.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/RabbitMQ.java @@ -11,16 +11,10 @@ @AllArgsConstructor @NoArgsConstructor public class RabbitMQ { + boolean state; + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime joinedAt; - - public void updateState(boolean state) { - this.state = state; - } - - public void updateJoinedAt(LocalDateTime joinedAt) { - this.joinedAt = joinedAt; - } - } + diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/TrackingSessionDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/TrackingSessionDto.java index 27f6f8e..cdad53d 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/TrackingSessionDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/dto/TrackingSessionDto.java @@ -1,6 +1,7 @@ package com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto; import com.example.emergencyassistb4b4.domain.volunteer.domain.AttendancePolicy; +import com.example.emergencyassistb4b4.domain.volunteer.domain.Post; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerLocation; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerTeam; import com.fasterxml.jackson.annotation.JsonFormat; @@ -11,36 +12,47 @@ import java.time.LocalDateTime; import java.util.List; + @Getter @Builder @AllArgsConstructor @NoArgsConstructor public class TrackingSessionDto { + + private Long postId; + private Long teamId; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime startTime; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime endTime; + private double targetLat; + private double targetLng; + private int meter; - private long intervalSeconds; + private List participantUserIds; + // 도메인 객체로부터 TrackingSessionDto 생성 public static TrackingSessionDto from( + Post post, VolunteerTeam team, VolunteerLocation location, AttendancePolicy policy, List participantUserIds ) { return TrackingSessionDto.builder() + .postId(post.getId()) .teamId(team.getId()) .participantUserIds(participantUserIds) .targetLat(location.getLocationLat()) .targetLng(location.getLocationLng()) .startTime(policy.getCheckinStart()) .endTime(policy.getCheckinEnd()) - .intervalSeconds(60L) .meter(policy.getAttendanceRadiusMeters()) .build(); } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceEventListener.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceEventListener.java index 1f354ad..5d57f28 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceEventListener.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceEventListener.java @@ -3,15 +3,17 @@ import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.RabbitMQ; import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.service.TrackingService; import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; + import io.lettuce.core.RedisException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.time.LocalDateTime; @@ -23,68 +25,64 @@ public class AttendanceEventListener { private final RabbitMQRedisService rabbitMQRedisService; private final TrackingService trackingService; - // 1. 출석 예약 상태 저장 + // 출석 예약 상태 저장 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 5000)) public void onAttendanceStateSet(AttendanceStateSetEvent event) { try { rabbitMQRedisService.scheduleTrackingStart(event.getTeamId(), event.getJoinedAt()); } catch (Exception e) { - log.error("[예약 등록 실패] teamId: {}", event.getTeamId(), e); + log.error("예약 등록 실패 - teamId: {}", event.getTeamId(), e); } } - // 2. 출석 시작 처리 + // 출석 시작 처리 public void onAttendanceStateChanged(Long teamId) { try { RabbitMQ state = rabbitMQRedisService.getTrackingState(teamId); if (shouldStart(state.getJoinedAt()) && !state.isState()) { trackingService.scheduleTrackingForTeam(teamId); - rabbitMQRedisService.updateTrackingState(teamId, state.getJoinedAt()); // state = true - log.info("[출석 시작] 출석 상태 변경 완료 - teamId: {}", teamId); - } else { - log.debug("[조건 미충족] 출석 시작 안함 - teamId: {}, shouldStart: {}, isNotStarted: {}", - teamId, shouldStart(state.getJoinedAt()), !state.isState()); + rabbitMQRedisService.updateTrackingState(teamId, state.getJoinedAt()); } } catch (RedisException e) { - log.error("[출석 시작 실패 - Redis 문제] teamId: {}", teamId, e); + log.error("출석 시작 실패 - Redis 문제, teamId: {}", teamId, e); } catch (Exception e) { - log.error("[출석 시작 실패 - 알 수 없는 오류] teamId: {}", teamId, e); + log.error("출석 시작 실패 - 알 수 없는 오류, teamId: {}", teamId, e); } } - // 3. 출석 종료 처리 + // 출석 종료 처리 public void onAttendanceEnded(Long teamId) { try { RabbitMQ state = rabbitMQRedisService.getTrackingState(teamId); if (state.isState()) { - clearTrackingStateWithLog(teamId, "[출석 종료] 출석 상태 삭제 완료", false); + clearTrackingState(teamId, false); } else if (shouldEnd(state.getJoinedAt())) { - clearTrackingStateWithLog(teamId, "[출석 종료 비정상] 출석 시작 안됨", true); - } else { - log.debug("[출석 종료 스킵] 아직 출석 시간 전 - teamId: {}, joinedAt: {}", teamId, state.getJoinedAt()); + clearTrackingState(teamId, true); } - } catch (RedisException e) { - log.error("[출석 종료 실패 - Redis 문제] teamId: {}", teamId, e); + log.error("출석 종료 실패 - Redis 문제, teamId: {}", teamId, e); } catch (Exception e) { - log.error("[출석 종료 실패 - 알 수 없는 오류] teamId: {}", teamId, e); + log.error("출석 종료 실패 - 알 수 없는 오류, teamId: {}", teamId, e); } } - // ---------------- 유틸 메서드 ---------------- - - private void clearTrackingStateWithLog(Long teamId, String message, boolean warn) { + // tracking 상태 삭제 + private void clearTrackingState(Long teamId, boolean warn) { rabbitMQRedisService.clearTrackingState(teamId); - if (warn) log.warn(message, teamId); + if (warn) { + log.warn("출석 종료 비정상 - 시작 안됨, teamId: {}", teamId); + } } + // 출석 시작 조건 체크 private boolean shouldStart(LocalDateTime joinedAt) { return LocalDateTime.now().isAfter(joinedAt.minusMinutes(5)); } + // 출석 종료 조건 체크 private boolean shouldEnd(LocalDateTime joinedAt) { return LocalDateTime.now().isAfter(joinedAt); } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceStateSetEvent.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceStateSetEvent.java index 5bd2755..8b697ec 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceStateSetEvent.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/AttendanceStateSetEvent.java @@ -6,13 +6,14 @@ @Getter public class AttendanceStateSetEvent { + private final Long teamId; private final LocalDateTime joinedAt; + // 출석 이벤트 생성 public AttendanceStateSetEvent(Long teamId, LocalDateTime joinedAt) { this.teamId = teamId; this.joinedAt = joinedAt; } } - diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/TrackingScheduleEvent.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/TrackingScheduleEvent.java deleted file mode 100644 index 91bf0e2..0000000 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/TrackingScheduleEvent.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.emergencyassistb4b4.domain.attendance.rabbitmq.event; - -import lombok.Getter; - -@Getter -public class TrackingScheduleEvent { - - private final Long teamId; - - public TrackingScheduleEvent(Long teamId) { - this.teamId = teamId; - } - -} \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/TrackingScheduleEventListener.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/TrackingScheduleEventListener.java deleted file mode 100644 index 92f840a..0000000 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/event/TrackingScheduleEventListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.example.emergencyassistb4b4.domain.attendance.rabbitmq.event; - -import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.service.TrackingService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Component -@RequiredArgsConstructor -public class TrackingScheduleEventListener { - - private final TrackingService trackingService; - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleTrackingScheduleEvent(TrackingScheduleEvent event) { - trackingService.scheduleTrackingForTeam(event.getTeamId()); - } - -} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/publisher/TrackingSessionPublisher.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/publisher/TrackingSessionPublisher.java index 744282c..572e07d 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/publisher/TrackingSessionPublisher.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/publisher/TrackingSessionPublisher.java @@ -1,10 +1,8 @@ package com.example.emergencyassistb4b4.domain.attendance.rabbitmq.publisher; import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.MessageWrapper; -import static com.example.emergencyassistb4b4.domain.attendance.rabbitmq.util.RabbitMqUtils.isValidMessage; + import org.springframework.beans.factory.annotation.Value; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.AmqpException; import org.springframework.amqp.core.MessagePostProcessor; import org.springframework.amqp.rabbit.core.RabbitTemplate; @@ -12,6 +10,11 @@ import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.example.emergencyassistb4b4.domain.attendance.rabbitmq.util.RabbitMqUtils.isValidMessage; + @Service @RequiredArgsConstructor @Slf4j @@ -25,9 +28,7 @@ public class TrackingSessionPublisher { @Value("${spring.rabbitmq.tracking.delayed-routing-key}") private String delayedRoutingKey; - /** - * 지연 메시지 전송 - */ + // 지연 메시지 전송 @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 2000)) public void publishDelayedTrackingSession(MessageWrapper messageWrapper, long delayMillis) { @@ -37,17 +38,14 @@ public void publishDelayedTrackingSession(MessageWrapper messageWrapper, long de } try { rabbitTemplate.convertAndSend(delayedExchangeName, delayedRoutingKey, messageWrapper, buildDelayedMessagePostProcessor(delayMillis)); - - log.info("Published delayed tracking session: participantCount={}, delay={}ms", - messageWrapper.getPayload().getParticipantUserIds().size(), delayMillis); - } catch (AmqpException e) { - log.error("Failed to publish delayed tracking session: participantCount={}, delay={}ms", + log.error("Delayed tracking session 발행 실패: participantCount={}, delay={}ms", messageWrapper.getPayload().getParticipantUserIds().size(), delayMillis, e); throw e; } } + // 메시지에 지연 설정 추가 private MessagePostProcessor buildDelayedMessagePostProcessor(long delayMillis) { return message -> { message.getMessageProperties().setHeader("x-delay", delayMillis); diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingDataService.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingDataService.java index 5bf014a..6913ee2 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingDataService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingDataService.java @@ -1,13 +1,16 @@ package com.example.emergencyassistb4b4.domain.attendance.rabbitmq.service; + import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; import com.example.emergencyassistb4b4.domain.volunteer.domain.VolunteerParticipant; import com.example.emergencyassistb4b4.domain.volunteer.enums.CheckinStatus; import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerParticipantRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -22,6 +25,7 @@ public class TrackingDataService { private static final int ATTENDANCE_THRESHOLD = 3; + // 세션 참여자 출석 데이터 저장 @Transactional public void saveSessionAttendanceData(List volunteerIds, Long teamId) { List updateList = new ArrayList<>(); @@ -48,19 +52,17 @@ public void saveSessionAttendanceData(List volunteerIds, Long teamId) { // DB 저장 후 Redis 삭제 volunteerIds.forEach(rabbitMQRedisService::clearAttendanceHistory); - - log.debug("참여자 출석 상태 {}건 저장 완료 (teamId={})", updateList.size(), teamId); } + // 개별 출석 기록 문자열을 Boolean으로 변환 private Optional parseRecordToBoolean(String record) { if (record == null || record.isBlank()) return Optional.empty(); - // "1" → true, "0" → false return switch (record) { case "1" -> Optional.of(true); case "0" -> Optional.of(false); default -> { - log.warn("Unknown attendance record value: {}", record); + log.warn("알 수 없는 출석 기록 값: {}", record); yield Optional.empty(); } }; diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingService.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingService.java index 19e1b45..2b2113b 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/service/TrackingService.java @@ -4,15 +4,17 @@ import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.SessionState; import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.TrackingSessionDto; import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.publisher.TrackingSessionPublisher; -import com.example.emergencyassistb4b4.domain.attendance.socket.handler.TrackingSocketHandler; +import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; import com.example.emergencyassistb4b4.domain.volunteer.domain.*; import com.example.emergencyassistb4b4.domain.volunteer.enums.CheckinStatus; import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerTeamRepository; import com.example.emergencyassistb4b4.global.exception.ApiException; import com.example.emergencyassistb4b4.global.status.ErrorStatus; + +import org.springframework.stereotype.Service; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; import java.time.Duration; import java.time.LocalDateTime; @@ -27,13 +29,12 @@ public class TrackingService { private final TrackingSessionPublisher trackingSessionPublisher; private final VolunteerTeamRepository volunteerTeamRepository; - private final TrackingSocketHandler trackingSocketHandler; - /** - * 팀에 대한 위치 추적 세션 예약 시작 - */ + private final RabbitMQRedisService rabbitMQRedisService; + + // 팀에 대한 위치 추적 세션 예약 시작 public void scheduleTrackingForTeam(Long teamId) { VolunteerTeam team = volunteerTeamRepository.findWithPostAndDetailsById(teamId) - .orElseThrow(()->new ApiException(ErrorStatus.TEAM_NOT_FOUND)); + .orElseThrow(() -> new ApiException(ErrorStatus.TEAM_NOT_FOUND)); Post post = team.getPost(); VolunteerLocation location = post.getLocation(); @@ -51,38 +52,30 @@ public void scheduleTrackingForTeam(Long teamId) { .map(VolunteerParticipant::getId) .toList(); - participants.forEach(p -> trackingSocketHandler.cacheVolunteerUserMapping(p.getId(), p.getUser().getId())); + participants.forEach(p -> rabbitMQRedisService.mapVolunteerToUser(p.getId(), p.getUser().getId())); - - TrackingSessionDto sessionDto = TrackingSessionDto.from(team, location, policy, participantUserIds); + TrackingSessionDto sessionDto = TrackingSessionDto.from(post, team, location, policy, participantUserIds); LocalDateTime checkinStart = policy.getCheckinStart(); - - // 1. READY 메시지 예약 (출석 시작 1분 전) + // READY 메시지 예약 (출석 시작 1분 전) LocalDateTime readyTime = checkinStart.minusMinutes(1); scheduleTrackingAtTime(new MessageWrapper(SessionState.READY, sessionDto), readyTime); - // 2. STARTED 메시지 예약 (출석 시작 시점) + // STARTED 메시지 예약 (출석 시작 시점) scheduleTrackingAtTime(new MessageWrapper(SessionState.STARTED, sessionDto), checkinStart); - // 3. ENDED 메시지 예약 (출석 종료 시점) + // ENDED 메시지 예약 (출석 종료 시점) LocalDateTime endTime = policy.getCheckinEnd(); scheduleTrackingAtTime(new MessageWrapper(SessionState.ENDED, sessionDto), endTime); - - log.debug("Tracking session scheduled: teamId={}, ready={}, start={}, end={}", - teamId, readyTime, checkinStart, endTime); } - - /** - * 메시지를 일정 시간 뒤에 발행 - */ + // 메시지를 일정 시간 뒤에 발행 public void scheduleTrackingAtTime(MessageWrapper wrapper, LocalDateTime scheduledTime) { long delayMillis = Duration.between(LocalDateTime.now(), scheduledTime).toMillis(); if (delayMillis < 0) { - log.warn("Scheduled time {} is in the past. Sending immediately.", scheduledTime); + log.warn("예약 시간이 과거임, 즉시 발송: {}", scheduledTime); delayMillis = 0; } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/util/RabbitMqUtils.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/util/RabbitMqUtils.java index 7ee2a46..a3a65ea 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/util/RabbitMqUtils.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/rabbitmq/util/RabbitMqUtils.java @@ -4,6 +4,7 @@ public class RabbitMqUtils { + // 메시지 유효성 체크 public static boolean isValidMessage(MessageWrapper message) { return message != null && message.getSessionState() != null diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisKeyUtil.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisKeyUtil.java index b9b6558..3c1bdc0 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisKeyUtil.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisKeyUtil.java @@ -1,6 +1,7 @@ package com.example.emergencyassistb4b4.domain.attendance.redis; public class RabbitMQRedisKeyUtil { + private static final String PREFIX_RABBITMQ_STATE = "attendance:rabbitmq:state:"; private static final String PREFIX_TEAM_TRACKING_STATE = "attendance:team:tracking:"; private static final String PREFIX_VOLUNTEER_USER = "attendance:volunteer:user:"; @@ -8,26 +9,32 @@ public class RabbitMQRedisKeyUtil { private static final String PREFIX_ATTENDANCE_SESSION = "attendance:session:"; private static final String VOLUNTEER_TEAM_PREFIX = "attendance:volunteer:team:"; + // RabbitMQ 상태 키 public static String rabbitMQStateKey(Long teamId) { return PREFIX_RABBITMQ_STATE + teamId; } + // 팀 트래킹 상태 키 public static String teamTrackingStateKey(Long teamId) { return PREFIX_TEAM_TRACKING_STATE + teamId; } + // 자원봉사자 사용자 키 public static String volunteerUserKey(Long volunteerId) { return PREFIX_VOLUNTEER_USER + volunteerId; } + // 팀 위치 정보 키 public static String geoKey(Long teamId) { return PREFIX_GEO + teamId; } + // 출석 세션 키 public static String attendanceSessionKey(Long volunteerId) { return PREFIX_ATTENDANCE_SESSION + volunteerId; } + // 자원봉사자 팀 키 public static String volunteerTeamKey(Long volunteerId) { return VOLUNTEER_TEAM_PREFIX + volunteerId; } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisRepository.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisRepository.java index df59264..df1b17b 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisRepository.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisRepository.java @@ -2,11 +2,13 @@ import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.dto.RabbitMQ; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; + import org.springframework.data.geo.*; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; +import lombok.RequiredArgsConstructor; + import java.time.Duration; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -25,23 +27,27 @@ public class RabbitMQRedisRepository { private static final String FIELD_TIMESTAMP = "timestamp"; private static final String FIELD_PRESENT = "present"; + // ---------------- RabbitMQ 상태 ---------------- - // === RabbitMQ 상태 === + // 팀의 RabbitMQ 상태 저장 public void saveRabbitMQState(Long teamId, LocalDateTime joinedAt) { RabbitMQ rabbitMQ = new RabbitMQ(false, joinedAt); redisTemplate.opsForValue().set(RabbitMQRedisKeyUtil.rabbitMQStateKey(teamId), rabbitMQ); } + // 팀의 RabbitMQ 상태 업데이트 public void updateRabbitMQState(Long teamId, LocalDateTime joinedAt) { RabbitMQ rabbitMQ = new RabbitMQ(true, joinedAt); redisTemplate.opsForValue().set(RabbitMQRedisKeyUtil.rabbitMQStateKey(teamId), rabbitMQ); } + // 팀의 RabbitMQ 상태 조회 public RabbitMQ getRabbitMQState(Long teamId) { Object obj = redisTemplate.opsForValue().get(RabbitMQRedisKeyUtil.rabbitMQStateKey(teamId)); return objectMapper.convertValue(obj, RabbitMQ.class); } + // 모든 팀의 트래킹 상태 조회 public Set getAllTrackingStates() { Set keys = redisTemplate.keys("attendance:rabbitmq:state:*"); if (keys == null) return Set.of(); @@ -52,28 +58,19 @@ public Set getAllTrackingStates() { .collect(Collectors.toSet()); } + // 팀의 RabbitMQ 상태 삭제 public void deleteRabbitMQState(Long teamId) { redisTemplate.delete(RabbitMQRedisKeyUtil.rabbitMQStateKey(teamId)); } - // === 팀 출석 상태 === - public void setTeamTrackingState(Long teamId, String value, long ttlSeconds) { - redisTemplate.opsForValue().set(RabbitMQRedisKeyUtil.teamTrackingStateKey(teamId), value, Duration.ofSeconds(ttlSeconds)); - } - - public String getTeamTrackingState(Long teamId) { - return (String) redisTemplate.opsForValue().get(RabbitMQRedisKeyUtil.teamTrackingStateKey(teamId)); - } - - public void deleteTeamTrackingState(Long teamId) { - redisTemplate.delete(RabbitMQRedisKeyUtil.teamTrackingStateKey(teamId)); - } + // ---------------- 자원봉사자 - 유저 매핑 ---------------- - // === 자원봉사자 ID → 유저 ID 매핑 === + // 자원봉사자 ID에 대한 유저 ID 저장 public void cacheUserIdForVolunteer(Long volunteerId, Long userId) { redisTemplate.opsForValue().set(RabbitMQRedisKeyUtil.volunteerUserKey(volunteerId), userId.toString()); } + // 자원봉사자 ID로 유저 ID 조회 public Long getUserIdForVolunteer(Long volunteerId) { String value = (String) redisTemplate.opsForValue().get(RabbitMQRedisKeyUtil.volunteerUserKey(volunteerId)); try { @@ -83,15 +80,19 @@ public Long getUserIdForVolunteer(Long volunteerId) { } } + // 자원봉사자 ID와 유저 매핑 삭제 public void deleteUserIdForVolunteer(Long volunteerId) { redisTemplate.delete(RabbitMQRedisKeyUtil.volunteerUserKey(volunteerId)); } - // === 자원봉사자 → 팀 매핑 === + // ---------------- 자원봉사자 - 팀 매핑 ---------------- + + // 자원봉사자 ID에 팀 ID 매핑 public void mapVolunteerToTeam(Long volunteerId, Long teamId) { redisTemplate.opsForValue().set(RabbitMQRedisKeyUtil.volunteerTeamKey(volunteerId), teamId.toString(), Duration.ofMinutes(30)); } + // 자원봉사자 ID로 팀 조회 public Long findTeamByVolunteer(Long volunteerId) { String value = (String) redisTemplate.opsForValue().get(RabbitMQRedisKeyUtil.volunteerTeamKey(volunteerId)); try { @@ -101,21 +102,21 @@ public Long findTeamByVolunteer(Long volunteerId) { } } - public void unmapVolunteerFromTeam(Long volunteerId) { - redisTemplate.delete(RabbitMQRedisKeyUtil.volunteerTeamKey(volunteerId)); - } + // ---------------- 팀 위치 GEO ---------------- - // === 팀 위치 GEO 저장 및 조회 === + // 팀의 위치 정보 저장 public void addTeamGeoLocation(Long teamId, double lat, double lon, Duration ttl) { String geoKey = RabbitMQRedisKeyUtil.geoKey(teamId); redisTemplate.opsForGeo().add(geoKey, new Point(lon, lat), "center"); redisTemplate.expire(geoKey, ttl); } + // 팀 위치 정보 존재 여부 확인 public boolean hasGeoKey(Long teamId) { return Boolean.TRUE.equals(redisTemplate.hasKey(RabbitMQRedisKeyUtil.geoKey(teamId))); } + // 특정 위치 반경 내 팀 존재 여부 확인 public boolean radiusSearch(Long teamId, double lat, double lon, int radiusMeters) { String geoKey = RabbitMQRedisKeyUtil.geoKey(teamId); Circle circle = new Circle(new Point(lon, lat), new Distance(radiusMeters, Metrics.NEUTRAL)); @@ -123,7 +124,9 @@ public boolean radiusSearch(Long teamId, double lat, double lon, int radiusMeter .anyMatch(r -> "center".equals(r.getContent().getName())); } - // === 출석 세션 기록 === + // ---------------- 출석 세션 ---------------- + + // 자원봉사자 출석 기록 저장 public void saveAttendanceRecord(Long volunteerId, boolean isPresent, Duration ttl) { String key = RabbitMQRedisKeyUtil.attendanceSessionKey(volunteerId); Map record = Map.of( @@ -134,12 +137,12 @@ public void saveAttendanceRecord(Long volunteerId, boolean isPresent, Duration t redisTemplate.expire(key, ttl); } + // 자원봉사자 출석 기록 조회 public List fetchAttendanceRecords(Long volunteerId) { String key = RabbitMQRedisKeyUtil.attendanceSessionKey(volunteerId); List objects = redisTemplate.opsForList().range(key, 0, -1); if (objects == null) return List.of(); - // present 값만 "1"/"0" 문자열로 변환 return objects.stream() .map(obj -> { Map map = objectMapper.convertValue(obj, Map.class); @@ -148,6 +151,7 @@ public List fetchAttendanceRecords(Long volunteerId) { .toList(); } + // 자원봉사자 출석 기록 삭제 public void deleteAttendanceRecords(Long volunteerId) { redisTemplate.delete(RabbitMQRedisKeyUtil.attendanceSessionKey(volunteerId)); } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisScheduler.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisScheduler.java index 3621316..49ca4c0 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisScheduler.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisScheduler.java @@ -1,11 +1,12 @@ package com.example.emergencyassistb4b4.domain.attendance.redis; - import com.example.emergencyassistb4b4.domain.attendance.rabbitmq.event.AttendanceEventListener; -import lombok.RequiredArgsConstructor; + import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class RabbitMQRedisScheduler { @@ -14,14 +15,16 @@ public class RabbitMQRedisScheduler { private final RabbitMQRedisService rabbitMQRedisService; @Scheduled(cron = "0 * * * * *") - public void ScheduledRun(){ - rabbitMQRedisService.getAllTrackingStates().forEach(attendanceEventListener::onAttendanceStateChanged); + // 모든 진행 중 트래킹 상태에 대해 출석 상태 변경 이벤트 처리 + public void runAttendanceStateCheck() { + rabbitMQRedisService.getAllTrackingStates() + .forEach(attendanceEventListener::onAttendanceStateChanged); } @Scheduled(cron = "0 */5 * * * *") - public void ScheduledRunDown(){ - rabbitMQRedisService.getAllTrackingStates().forEach(attendanceEventListener::onAttendanceEnded); + // 모든 진행 중 트래킹 상태에 대해 출석 종료 이벤트 처리 + public void runAttendanceEndCheck() { + rabbitMQRedisService.getAllTrackingStates() + .forEach(attendanceEventListener::onAttendanceEnded); } - - } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisService.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisService.java index 08d0b13..198f593 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/redis/RabbitMQRedisService.java @@ -16,11 +16,9 @@ public class RabbitMQRedisService { private final RabbitMQRedisRepository rabbitMQRedisRepository; - private static final String TRACKING_STARTED = "TRACKING_STARTED"; - private static final Duration TTL = Duration.ofMinutes(30); - - // ======================== RabbitMQ 상태 ======================== + // ---------------- RabbitMQ 상태 ---------------- + // 출석 예약 상태 저장 public void scheduleTrackingStart(Long teamId, LocalDateTime joinedAt) { if (joinedAt == null || joinedAt.isBefore(LocalDateTime.now())) { log.warn("Invalid joinedAt for teamId {}: {}", teamId, joinedAt); @@ -29,6 +27,7 @@ public void scheduleTrackingStart(Long teamId, LocalDateTime joinedAt) { rabbitMQRedisRepository.saveRabbitMQState(teamId, joinedAt); } + // 출석 상태 업데이트 public void updateTrackingState(Long teamId, LocalDateTime joinedAt) { if (joinedAt == null || joinedAt.isBefore(LocalDateTime.now())) { log.warn("Attempted to update past joinedAt for teamId {}: {}", teamId, joinedAt); @@ -37,83 +36,82 @@ public void updateTrackingState(Long teamId, LocalDateTime joinedAt) { rabbitMQRedisRepository.updateRabbitMQState(teamId, joinedAt); } + // 출석 상태 조회 public RabbitMQ getTrackingState(Long teamId) { return rabbitMQRedisRepository.getRabbitMQState(teamId); } + // 모든 트래킹 상태 팀 ID 조회 public List getAllTrackingStates() { - return rabbitMQRedisRepository.getAllTrackingStates().stream() - .toList(); + return rabbitMQRedisRepository.getAllTrackingStates().stream().toList(); } + // 출석 상태 삭제 public void clearTrackingState(Long teamId) { rabbitMQRedisRepository.deleteRabbitMQState(teamId); } - // ======================== 출석 상태 ======================== - - public void markTrackingStarted(Long teamId) { - rabbitMQRedisRepository.setTeamTrackingState(teamId, TRACKING_STARTED, TTL.getSeconds()); - } - - public boolean isTrackingOngoing(Long teamId) { - return TRACKING_STARTED.equals(rabbitMQRedisRepository.getTeamTrackingState(teamId)); - } - - public void clearTrackingStatus(Long teamId) { - rabbitMQRedisRepository.deleteTeamTrackingState(teamId); - } - - // ======================== 자원봉사자 - 유저 매핑 ======================== + // ---------------- 자원봉사자 - 유저 매핑 ---------------- + // 자원봉사자와 유저 매핑 public void mapVolunteerToUser(Long volunteerId, Long userId) { rabbitMQRedisRepository.cacheUserIdForVolunteer(volunteerId, userId); } + // 자원봉사자로 유저 조회 public Long findUserIdByVolunteer(Long volunteerId) { return rabbitMQRedisRepository.getUserIdForVolunteer(volunteerId); } + // 자원봉사자와 유저 매핑 제거 public void unmapVolunteerFromUser(Long volunteerId) { rabbitMQRedisRepository.deleteUserIdForVolunteer(volunteerId); } + // ---------------- 팀 위치 ---------------- - // ======================== 팀 위치 ======================== - + // 팀 위치 갱신 public void updateTeamLocation(Long teamId, double lat, double lon, Duration ttl) { - rabbitMQRedisRepository.addTeamGeoLocation(teamId, lat, lon, ttl ); + rabbitMQRedisRepository.addTeamGeoLocation(teamId, lat, lon, ttl); } + // 팀 위치 존재 여부 확인 public boolean locationExists(Long teamId) { return rabbitMQRedisRepository.hasGeoKey(teamId); } + // 팀 위치 반경 확인 public boolean isWithinRadius(Long teamId, double lat, double lon, int radiusMeters) { return rabbitMQRedisRepository.radiusSearch(teamId, lat, lon, radiusMeters); } - // ======================== 자원봉사자 - 팀 매핑 ======================== + // ---------------- 자원봉사자 - 팀 매핑 ---------------- + + // 자원봉사자와 팀 매핑 public void mapVolunteerToTeam(Long volunteerId, Long teamId) { rabbitMQRedisRepository.mapVolunteerToTeam(volunteerId, teamId); } + // 자원봉사자로 팀 조회 public Long findTeamByVolunteer(Long volunteerId) { return rabbitMQRedisRepository.findTeamByVolunteer(volunteerId); } - // ======================== 출석 세션 ======================== + // ---------------- 출석 세션 ---------------- + // 출석 기록 저장 public void recordAttendance(Long volunteerId, boolean isPresent, Duration ttl) { - rabbitMQRedisRepository.saveAttendanceRecord(volunteerId, isPresent,ttl); + rabbitMQRedisRepository.saveAttendanceRecord(volunteerId, isPresent, ttl); } + // 출석 기록 조회 public List fetchAttendanceRecords(Long volunteerId) { return rabbitMQRedisRepository.fetchAttendanceRecords(volunteerId).stream() .map(Object::toString) .toList(); } + // 출석 기록 삭제 public void clearAttendanceHistory(Long volunteerId) { rabbitMQRedisRepository.deleteAttendanceRecords(volunteerId); } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/controller/LocationTrackingWebSocketHandler.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/controller/LocationTrackingWebSocketHandler.java index 5bbbc94..5b9208b 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/controller/LocationTrackingWebSocketHandler.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/controller/LocationTrackingWebSocketHandler.java @@ -1,110 +1,48 @@ package com.example.emergencyassistb4b4.domain.attendance.socket.controller; -import com.example.emergencyassistb4b4.domain.attendance.socket.dto.LocationUpdateMessage; -import com.example.emergencyassistb4b4.domain.attendance.socket.dto.WebSocketMessageWrapper; -import com.example.emergencyassistb4b4.domain.attendance.socket.service.LocationWebSocketService; -import com.example.emergencyassistb4b4.domain.location.service.LocationService; -import com.example.emergencyassistb4b4.domain.attendance.socket.notifier.TrackingNotifier; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.example.emergencyassistb4b4.domain.attendance.socket.service.LocationTrackingService; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.stereotype.Component; import org.springframework.web.socket.*; -@Slf4j -@RequiredArgsConstructor @Component +@RequiredArgsConstructor public class LocationTrackingWebSocketHandler implements WebSocketHandler { - private final LocationService locationService; - private final LocationWebSocketService locationWebSocketService; - private final TrackingNotifier trackingNotifier; - private final ObjectMapper objectMapper; + private final LocationTrackingService trackingService; @Override - public void afterConnectionEstablished(WebSocketSession session) { - log.info("웹소켓 연결됨, sessionId={}", session.getId()); - } + // 웹소켓 연결 후 초기 처리 + public void afterConnectionEstablished(WebSocketSession session) {} @Override + // 수신된 메시지 처리 public void handleMessage(WebSocketSession session, WebSocketMessage message) { - if (!(message instanceof TextMessage textMessage)) { - log.warn("지원하지 않는 메시지 타입: {}", message.getClass()); - return; - } - - try { - processLocationMessage(textMessage.getPayload()); - } catch (Exception e) { - log.error("메시지 처리 중 오류 발생", e); - } - } - - private void processLocationMessage(String payload) { - WebSocketMessageWrapper wrapper; - try { - wrapper = objectMapper.readValue(payload, WebSocketMessageWrapper.class); - } catch (JsonProcessingException e) { - log.warn("JSON 파싱 실패, payload={}", payload, e); - return; - } - - LocationUpdateMessage locMsg = wrapper.getData(); - if (locMsg == null) { - log.warn("빈 데이터 수신, payload={}", payload); - return; - } - - Long volunteerId = locMsg.getVolunteerId(); - double lat = locMsg.getLatitude(); - double lon = locMsg.getLongitude(); - - if (volunteerId == null || !isValidCoordinates(lat, lon)) { - log.warn("비정상 데이터 수신, volunteerId={}, lat={}, lon={}", volunteerId, lat, lon); - return; - } - - log.debug("Received location update from volunteerId={} lat={} lon={}", volunteerId, lat, lon); - - // 1. Redis GEO 저장 - locationService.saveCoordinates(volunteerId, lat, lon); - - // 2. 출석 체크 수행 - boolean isPresent = locationWebSocketService.checkAttendanceForVolunteer(volunteerId, lat, lon); - - // 3. 출석 상태 웹소켓 알림 전송 - trackingNotifier.notifyTrackingCheck(volunteerId, isPresent); - - // 4. Redis 저장 및 publish - locationWebSocketService.saveAndPublishAttendance(volunteerId, isPresent); - } - - private boolean isValidCoordinates(double lat, double lon) { - return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; + if (!(message instanceof TextMessage textMessage)) return; + trackingService.processMessage(textMessage.getPayload()); } @Override + // 웹소켓 전송 오류 처리 public void handleTransportError(WebSocketSession session, Throwable exception) { - log.error("전송 오류 발생", exception); closeSessionSilently(session); } @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - log.info("웹소켓 연결 종료, sessionId={}, status={}", session.getId(), status); - } + // 웹소켓 연결 종료 처리 + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {} @Override + // 부분 메시지 지원 여부 public boolean supportsPartialMessages() { return false; } + // 웹소켓 세션 안전하게 종료 private void closeSessionSilently(WebSocketSession session) { try { if (session.isOpen()) session.close(CloseStatus.SERVER_ERROR); - } catch (Exception e) { - log.error("세션 닫기 실패", e); - } + } catch (Exception ignored) {} } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/dto/LocationUpdateMessage.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/dto/LocationUpdateMessage.java index 7ab856d..bf5ae93 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/dto/LocationUpdateMessage.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/dto/LocationUpdateMessage.java @@ -1,13 +1,18 @@ package com.example.emergencyassistb4b4.domain.attendance.socket.dto; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor +@AllArgsConstructor public class LocationUpdateMessage { + private Long volunteerId; + private double latitude; + private double longitude; } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/dto/WebSocketMessageWrapper.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/dto/WebSocketMessageWrapper.java index 294603f..a27e552 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/dto/WebSocketMessageWrapper.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/dto/WebSocketMessageWrapper.java @@ -1,19 +1,15 @@ package com.example.emergencyassistb4b4.domain.attendance.socket.dto; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor +@AllArgsConstructor public class WebSocketMessageWrapper { - private String type; - private LocationUpdateMessage data; - public void setType(String type) { - this.type = type; - } + private String type; - public void setData(LocationUpdateMessage data) { - this.data = data; - } + private LocationUpdateMessage data; } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/TrackingSocketHandler.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/TrackingSocketHandler.java index b5082cc..8b7edeb 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/TrackingSocketHandler.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/TrackingSocketHandler.java @@ -1,115 +1,63 @@ package com.example.emergencyassistb4b4.domain.attendance.socket.handler; import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; -import com.example.emergencyassistb4b4.domain.attendance.socket.service.LocationWebSocketService; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.socket.*; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -@Slf4j @Component @RequiredArgsConstructor public class TrackingSocketHandler implements WebSocketHandler { private final RabbitMQRedisService rabbitMQRedisService; - private final LocationWebSocketService locationWebSocketService; - private final Map> userSessions = new ConcurrentHashMap<>(); - private final ObjectMapper objectMapper; + private final WebSocketSessionManager sessionManager; + private final WebSocketMessageSender messageSender; + // 클라이언트와 WebSocket 연결이 성공적으로 맺어졌을 때 호출 @Override public void afterConnectionEstablished(WebSocketSession session) { Long userId = (Long) session.getAttributes().get("userId"); - if (userId == null) { - closeSession(session, CloseStatus.NOT_ACCEPTABLE); - return; - } - userSessions.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(session); - } - - private void closeSession(WebSocketSession session, CloseStatus status) { - try { - if (session.isOpen()) session.close(status); - } catch (Exception e) { - log.error("세션 종료 실패", e); - } + if (userId != null) sessionManager.addSession(userId, session); + else closeSession(session, CloseStatus.NOT_ACCEPTABLE); } + // 클라이언트가 보낸 메시지를 처리 (현재는 미사용) @Override - public void handleMessage(WebSocketSession session, WebSocketMessage message) { - // 필요 시 구현 - } + public void handleMessage(WebSocketSession session, WebSocketMessage message) {} + // 연결 중 에러가 발생했을 때 호출 @Override public void handleTransportError(WebSocketSession session, Throwable exception) { - log.error("WebSocket 전송 오류", exception); closeSession(session, CloseStatus.SERVER_ERROR); - removeSession(session); + sessionManager.removeSession(session); } + // 연결이 정상적으로 종료되었을 때 호출 @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - log.info("WebSocket 연결 종료: sessionId={}", session.getId()); - removeSession(session); + sessionManager.removeSession(session); } + // 부분 메시지를 지원하는지 여부 (현재는 false) @Override - public boolean supportsPartialMessages() { - return false; - } - - private void removeSession(WebSocketSession session) { - userSessions.values().forEach(sessions -> sessions.remove(session)); - userSessions.entrySet().removeIf(entry -> entry.getValue().isEmpty()); - } + public boolean supportsPartialMessages() { return false; } - // ================== Redis 캐싱 ================== - public void cacheVolunteerUserMapping(Long volunteerId, Long userId) { - rabbitMQRedisService.mapVolunteerToUser(volunteerId, userId); - } - - public Long getUserIdByVolunteerId(Long volunteerParticipantId) { - return rabbitMQRedisService.findUserIdByVolunteer(volunteerParticipantId); - } - - public void removeVolunteerUserMapping(Long volunteerParticipantId) { - rabbitMQRedisService.unmapVolunteerFromUser(volunteerParticipantId); + // 세션을 안전하게 종료 + private void closeSession(WebSocketSession session, CloseStatus status) { + try { + if (session.isOpen()) session.close(status); + } catch (Exception ignored) {} } - // ================== WebSocket 메시지 전송 ================== - @Transactional(readOnly = true) + // 특정 자원봉사자에게 메시지를 전송 (세션 없을 경우 실패 처리) public void sendToUser(Long volunteerId, String event, Object payload) { - Long userId = getUserIdByVolunteerId(volunteerId); - if (userId == null) { - log.warn("유저 매핑 없음: volunteerId={}", volunteerId); - locationWebSocketService.saveAndPublishAttendance(volunteerId, false); - return; - } - - Set sessions = userSessions.get(userId); - WebSocketSession session = (sessions != null && !sessions.isEmpty()) ? sessions.iterator().next() : null; + Long userId = rabbitMQRedisService.findUserIdByVolunteer(volunteerId); + WebSocketSession session = (userId != null) ? sessionManager.getSession(userId) : null; - if (session == null || !session.isOpen()) { - log.warn("웹소켓 세션 없음 또는 닫힘: volunteerId={}, userId={}", volunteerId, userId); - locationWebSocketService.saveAndPublishAttendance(volunteerId, false); - removeVolunteerUserMapping(volunteerId); - return; - } - - try { - String json = objectMapper.writeValueAsString(Map.of("type", event, "data", payload)); - session.sendMessage(new TextMessage(json)); - log.debug("웹소켓 전송 성공: volunteerId={}, userId={}, event={}", volunteerId, userId, event); - } catch (Exception e) { - log.error("웹소켓 전송 실패: volunteerId={}, event={}", volunteerId, event, e); - locationWebSocketService.saveAndPublishAttendance(volunteerId, false); - removeVolunteerUserMapping(volunteerId); + if (session != null) { + messageSender.sendMessage(volunteerId, event, payload, session); + } else { + messageSender.handleSendFailure(volunteerId); } } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/WebSocketMessageSender.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/WebSocketMessageSender.java new file mode 100644 index 0000000..68ec684 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/WebSocketMessageSender.java @@ -0,0 +1,36 @@ +package com.example.emergencyassistb4b4.domain.attendance.socket.handler; + +import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; +import com.example.emergencyassistb4b4.domain.attendance.socket.service.LocationWebSocketService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class WebSocketMessageSender { + private final ObjectMapper objectMapper; + private final LocationWebSocketService locationWebSocketService; + private final RabbitMQRedisService rabbitMQRedisService; + + // 지정된 WebSocket 세션에 이벤트 타입과 데이터를 JSON 메시지로 변환해 전송 + public void sendMessage(Long volunteerId, String event, Object payload, WebSocketSession session) { + try { + String json = objectMapper.writeValueAsString(Map.of("type", event, "data", payload)); + session.sendMessage(new TextMessage(json)); + } catch (Exception e) { + handleSendFailure(volunteerId); + } + } + + // 메시지 전송 실패 시 출석 상태를 미참석(false)으로 저장하고 + // 해당 봉사자를 사용자 매핑에서 제거 + public void handleSendFailure(Long volunteerId) { + locationWebSocketService.saveAndPublishAttendance(volunteerId, false); + rabbitMQRedisService.unmapVolunteerFromUser(volunteerId); + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/WebSocketSessionManager.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/WebSocketSessionManager.java new file mode 100644 index 0000000..0739de8 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/handler/WebSocketSessionManager.java @@ -0,0 +1,32 @@ +package com.example.emergencyassistb4b4.domain.attendance.socket.handler; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketSession; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class WebSocketSessionManager { + private final Map> userSessions = new ConcurrentHashMap<>(); + + // 특정 사용자에게 WebSocket 세션을 추가 + public void addSession(Long userId, WebSocketSession session) { + userSessions.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(session); + } + + // 세션을 제거하고 비어 있는 사용자 매핑도 정리 + public void removeSession(WebSocketSession session) { + userSessions.values().forEach(sessions -> sessions.remove(session)); + userSessions.entrySet().removeIf(entry -> entry.getValue().isEmpty()); + } + + // 특정 사용자에게 연결된 세션을 가져옴 (여러 세션 중 하나 반환) + public WebSocketSession getSession(Long userId) { + Set sessions = userSessions.get(userId); + if (sessions == null || sessions.isEmpty()) return null; + WebSocketSession session = sessions.iterator().next(); + return (session != null && session.isOpen()) ? session : null; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/message/AttendanceStatusMessage.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/message/AttendanceStatusMessage.java index 57d05bd..4540078 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/message/AttendanceStatusMessage.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/message/AttendanceStatusMessage.java @@ -1,16 +1,15 @@ package com.example.emergencyassistb4b4.domain.attendance.socket.message; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor +@AllArgsConstructor public class AttendanceStatusMessage { + private Long volunteerId; - private boolean present; - public AttendanceStatusMessage(Long volunteerId, boolean present) { - this.volunteerId = volunteerId; - this.present = present; - } + private boolean present; } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/message/TrackingMessage.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/message/TrackingMessage.java index 898bffa..f4a36b1 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/message/TrackingMessage.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/message/TrackingMessage.java @@ -4,10 +4,12 @@ import lombok.Getter; import lombok.NoArgsConstructor; +@Getter @AllArgsConstructor @NoArgsConstructor(force = true) -@Getter -public class TrackingMessage { - private final String type; - private final String content; +public class TrackingMessage { + + private String type; + + private T content; } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/notifier/TrackingNotifier.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/notifier/TrackingNotifier.java index e82bb9e..f67b5d5 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/notifier/TrackingNotifier.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/notifier/TrackingNotifier.java @@ -1,7 +1,9 @@ package com.example.emergencyassistb4b4.domain.attendance.socket.notifier; -import com.fasterxml.jackson.databind.ObjectMapper; import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; +import com.example.emergencyassistb4b4.domain.attendance.socket.message.AttendanceStatusMessage; +import com.example.emergencyassistb4b4.domain.attendance.socket.message.TrackingMessage; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -18,28 +20,25 @@ public class TrackingNotifier { private final ObjectMapper objectMapper = new ObjectMapper(); - private final RabbitMQRedisService rabbitMQRedisService; // RabbitMQRedisService 주입 + private final RabbitMQRedisService rabbitMQRedisService; - // userId → WebSocket 세션 저장 (예: TrackingSocketHandler 등에서 관리) + // userId → WebSocket 세션 저장 private final Map> userSessions = new ConcurrentHashMap<>(); + //출석 상태를 특정 volunteerId와 연결된 유저에게 전송 public void notifyTrackingCheck(Long volunteerId, boolean isPresent) { Long userId = getUserIdByVolunteerId(volunteerId); - if (userId == null) { - log.warn("volunteerId={}에 매핑된 userId가 없습니다.", volunteerId); - return; - } + if (userId == null) return; Set sessions = userSessions.get(userId); if (sessions == null || sessions.isEmpty()) return; - Map payload = Map.of( - "type", "attendance_status", - "data", Map.of("volunteerId", volunteerId, "isPresent", isPresent) - ); + AttendanceStatusMessage statusMessage = new AttendanceStatusMessage(volunteerId, isPresent); + TrackingMessage message = + new TrackingMessage<>("attendance_status", statusMessage); try { - String json = objectMapper.writeValueAsString(payload); + String json = objectMapper.writeValueAsString(message); for (WebSocketSession session : sessions) { if (session.isOpen()) { session.sendMessage(new TextMessage(json)); @@ -50,6 +49,7 @@ public void notifyTrackingCheck(Long volunteerId, boolean isPresent) { } } + //redis에서 사용자 아이디 조회 private Long getUserIdByVolunteerId(Long volunteerId) { try { return rabbitMQRedisService.findUserIdByVolunteer(volunteerId); diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationTrackingService.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationTrackingService.java new file mode 100644 index 0000000..99f9723 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationTrackingService.java @@ -0,0 +1,60 @@ +package com.example.emergencyassistb4b4.domain.attendance.socket.service; + +import com.example.emergencyassistb4b4.domain.attendance.socket.dto.LocationUpdateMessage; +import com.example.emergencyassistb4b4.domain.attendance.socket.dto.WebSocketMessageWrapper; +import com.example.emergencyassistb4b4.domain.attendance.socket.utils.WebSocketUtils; +import com.example.emergencyassistb4b4.domain.location.service.LocationService; +import com.example.emergencyassistb4b4.domain.attendance.socket.notifier.TrackingNotifier; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LocationTrackingService { + + private final LocationService locationService; + private final LocationWebSocketService locationWebSocketService; + private final TrackingNotifier trackingNotifier; + private final ObjectMapper objectMapper; + + /** + * 수신된 웹소켓 메시지를 처리하고, + * 1) 위치 정보를 Redis GEO에 저장 + * 2) 출석 체크 수행 + * 3) 출석 상태 알림 전송 + * 4) Redis 저장 및 publish 수행 + * + * @param payload 웹소켓에서 전달된 메시지 JSON 문자열 + */ + public void processMessage(String payload) { + WebSocketMessageWrapper wrapper; + try { + wrapper = objectMapper.readValue(payload, WebSocketMessageWrapper.class); + } catch (Exception e) { + return; // JSON 파싱 실패 + } + + LocationUpdateMessage locMsg = wrapper.getData(); + if (locMsg == null) return; + + Long volunteerId = locMsg.getVolunteerId(); + double lat = locMsg.getLatitude(); + double lon = locMsg.getLongitude(); + if (volunteerId == null || !WebSocketUtils.isValidCoordinates(lat, lon)) return; + + // 1. Redis GEO 저장 + locationService.saveCoordinates(volunteerId, lat, lon); + + // 2. 출석 체크 + boolean isPresent = locationWebSocketService.checkAttendanceForVolunteer(volunteerId, lat, lon); + + // 3. 알림 전송 + trackingNotifier.notifyTrackingCheck(volunteerId, isPresent); + + // 4. Redis 저장 및 publish + locationWebSocketService.saveAndPublishAttendance(volunteerId, isPresent); + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationWebSocketService.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationWebSocketService.java index 13ceba7..3dd0113 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationWebSocketService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/service/LocationWebSocketService.java @@ -1,20 +1,20 @@ package com.example.emergencyassistb4b4.domain.attendance.socket.service; import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; +import com.example.emergencyassistb4b4.domain.attendance.socket.utils.LocationWebSocketUtils; import com.example.emergencyassistb4b4.domain.volunteer.domain.*; import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerParticipantRepository; import com.example.emergencyassistb4b4.global.exception.ApiException; +import com.example.emergencyassistb4b4.domain.attendance.socket.message.AttendanceStatusMessage; + import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.stereotype.Service; import java.time.Duration; -import java.time.LocalDateTime; -import static com.example.emergencyassistb4b4.global.status.ErrorStatus.ATTENDANCE_LOCATION_OR_POLICY_MISSING; import static com.example.emergencyassistb4b4.global.status.ErrorStatus.VOLUNTEER_NOT_FOUND; -@Slf4j @Service @RequiredArgsConstructor public class LocationWebSocketService { @@ -23,74 +23,32 @@ public class LocationWebSocketService { private final RabbitMQRedisService rabbitMQRedisService; private static final int DEFAULT_RADIUS_METERS = 50; - private static final int DEFAULT_TTL_MINUTES = 3; - - /** - * 해당 자원봉사자가 팀 위치 반경 내에 있는지 체크 - */ - public boolean checkAttendanceForVolunteer(Long volunteerId, double lat, double lon) { - VolunteerParticipant participant = participantRepository - .findWithTeamAndPolicyById(volunteerId) - .orElseThrow(() -> new ApiException(VOLUNTEER_NOT_FOUND)); - Long teamId = rabbitMQRedisService.findTeamByVolunteer(volunteerId); - if (teamId == null) { - teamId = participant.getVolunteerTeam().getId(); - rabbitMQRedisService.mapVolunteerToTeam(volunteerId, teamId); - log.debug("Cached teamId={} for volunteerId={}", teamId, volunteerId); - } + // ================== 출석 체크 관련 ================== - cacheTeamLocationIfAbsent(participant, teamId); - - int radius = participant.getVolunteerTeam().getPost().getAttendancePolicy() != null - ? participant.getVolunteerTeam().getPost().getAttendancePolicy().getAttendanceRadiusMeters() - : DEFAULT_RADIUS_METERS; - - boolean withinRadius = rabbitMQRedisService.isWithinRadius(teamId, lat, lon, radius); - log.debug("Geo check for teamId={} lat={}, lon={}, radius={}m: {}", teamId, lat, lon, radius, withinRadius); - - return withinRadius; + //자원봉사자 위치 기반 출석 체크 + public boolean checkAttendanceForVolunteer(Long volunteerId, double lat, double lon) { + VolunteerParticipant participant = getParticipantOrThrow(volunteerId); + Long teamId = LocationWebSocketUtils.getOrCacheTeamId(volunteerId, participant, rabbitMQRedisService); + LocationWebSocketUtils.ensureTeamLocationCached(participant, teamId, rabbitMQRedisService); + int radius = LocationWebSocketUtils.getAttendanceRadius(participant, DEFAULT_RADIUS_METERS); + return LocationWebSocketUtils.isWithinRadius(teamId, lat, lon, radius, rabbitMQRedisService); } - private void cacheTeamLocationIfAbsent(VolunteerParticipant participant, Long teamId) { - if (rabbitMQRedisService.locationExists(teamId)) return; + // ================== 출석 기록 저장 ================== - VolunteerTeam team = participant.getVolunteerTeam(); - Post post = team.getPost(); - VolunteerLocation location = post.getLocation(); - AttendancePolicy policy = post.getAttendancePolicy(); - - if (location == null || policy == null) { - throw new ApiException(ATTENDANCE_LOCATION_OR_POLICY_MISSING); - } - - Duration ttl = Duration.between(LocalDateTime.now(), policy.getCheckinEnd()) - .plusMinutes(DEFAULT_TTL_MINUTES); - if (ttl.isNegative() || ttl.isZero()) ttl = Duration.ofMinutes(DEFAULT_TTL_MINUTES); + //Redis에 출석 기록 저장 및 publish + public void saveAndPublishAttendance(Long volunteerId, boolean isPresent) { + Duration ttl = LocationWebSocketUtils.computeTTL(volunteerId, participantRepository); + rabbitMQRedisService.recordAttendance(volunteerId, isPresent, ttl); - rabbitMQRedisService.updateTeamLocation(teamId, location.getLocationLat(), location.getLocationLng(), ttl); - log.debug("Cached geo center for teamId={} at lat={}, lon={}, ttl={}s", teamId, location.getLocationLat(), location.getLocationLng(), ttl.getSeconds()); + // DTO 생성 (WebSocket 전송용) + AttendanceStatusMessage message = new AttendanceStatusMessage(volunteerId, isPresent); } - /** - * Redis에 출석 기록 저장 및 publish - */ - public void saveAndPublishAttendance(Long volunteerId, boolean isPresent) { - VolunteerParticipant participant = participantRepository - .findWithTeamAndPolicyById(volunteerId) + // ================== Private Helpers ================== + private VolunteerParticipant getParticipantOrThrow(Long volunteerId) { + return participantRepository.findWithTeamAndPolicyById(volunteerId) .orElseThrow(() -> new ApiException(VOLUNTEER_NOT_FOUND)); - - AttendancePolicy policy = participant.getVolunteerTeam().getPost().getAttendancePolicy(); - LocalDateTime now = LocalDateTime.now(); - LocalDateTime sessionEnd = (policy != null && policy.getCheckinEnd() != null) - ? policy.getCheckinEnd() - : now; - - Duration ttl = Duration.between(now, sessionEnd.isAfter(now) ? sessionEnd : now) - .plusMinutes(DEFAULT_TTL_MINUTES); - if (ttl.isNegative() || ttl.isZero()) ttl = Duration.ofMinutes(DEFAULT_TTL_MINUTES); - - rabbitMQRedisService.recordAttendance(volunteerId, isPresent, ttl); - log.info("Saved attendance: volunteerId={}, isPresent={}, ttl={}s", volunteerId, isPresent, ttl.getSeconds()); } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/utils/LocationWebSocketUtils.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/utils/LocationWebSocketUtils.java new file mode 100644 index 0000000..a3074a5 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/utils/LocationWebSocketUtils.java @@ -0,0 +1,75 @@ +package com.example.emergencyassistb4b4.domain.attendance.socket.utils; + +import com.example.emergencyassistb4b4.domain.attendance.redis.RabbitMQRedisService; +import com.example.emergencyassistb4b4.domain.volunteer.domain.*; +import com.example.emergencyassistb4b4.domain.volunteer.repository.VolunteerParticipantRepository; +import com.example.emergencyassistb4b4.global.exception.ApiException; + +import java.time.Duration; +import java.time.LocalDateTime; + +import static com.example.emergencyassistb4b4.global.status.ErrorStatus.ATTENDANCE_LOCATION_OR_POLICY_MISSING; + +public class LocationWebSocketUtils { + + // ================== 팀 캐싱 관련 ================== + + //자원봉사자의 팀 ID를 Redis에서 조회하거나 없으면 캐싱 + public static Long getOrCacheTeamId(Long volunteerId, VolunteerParticipant participant, + RabbitMQRedisService rabbitMQRedisService) { + Long teamId = rabbitMQRedisService.findTeamByVolunteer(volunteerId); + if (teamId == null) { + teamId = participant.getVolunteerTeam().getId(); + rabbitMQRedisService.mapVolunteerToTeam(volunteerId, teamId); + } + return teamId; + } + + //팀 위치가 Redis에 캐싱되어 있는지 확인하고, 없으면 캐싱 + public static void ensureTeamLocationCached(VolunteerParticipant participant, Long teamId, + RabbitMQRedisService rabbitMQRedisService) { + if (rabbitMQRedisService.locationExists(teamId)) return; + + VolunteerTeam team = participant.getVolunteerTeam(); + Post post = team.getPost(); + VolunteerLocation location = post.getLocation(); + AttendancePolicy policy = post.getAttendancePolicy(); + + if (location == null || policy == null) throw new ApiException(ATTENDANCE_LOCATION_OR_POLICY_MISSING); + + Duration ttl = computeTTL(policy); + rabbitMQRedisService.updateTeamLocation(teamId, location.getLocationLat(), location.getLocationLng(), ttl); + } + + // ================== 출석 반경/거리 관련 ================== + + //자원봉사자의 출석 정책에서 출석 반경을 가져오거나, 없으면 기본값 사용 + public static int getAttendanceRadius(VolunteerParticipant participant, int defaultRadius) { + AttendancePolicy policy = participant.getVolunteerTeam().getPost().getAttendancePolicy(); + return policy != null ? policy.getAttendanceRadiusMeters() : defaultRadius; + } + + //특정 좌표가 팀 중심 위치에서 지정 반경 내에 있는지 확인 + public static boolean isWithinRadius(Long teamId, double lat, double lon, int radius, + RabbitMQRedisService rabbitMQRedisService) { + return rabbitMQRedisService.isWithinRadius(teamId, lat, lon, radius); + } + + // ================== TTL 계산 ================== + + //자원봉사자 ID 기반으로 출석 TTL 계산 + public static Duration computeTTL(Long volunteerId, VolunteerParticipantRepository participantRepository) { + VolunteerParticipant participant = participantRepository.findWithTeamAndPolicyById(volunteerId) + .orElseThrow(); + AttendancePolicy policy = participant.getVolunteerTeam().getPost().getAttendancePolicy(); + return computeTTL(policy); + } + + //출석 정책 기준 TTL 계산 + public static Duration computeTTL(AttendancePolicy policy) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime sessionEnd = (policy != null && policy.getCheckinEnd() != null) ? policy.getCheckinEnd() : now; + Duration ttl = Duration.between(now, sessionEnd.isAfter(now) ? sessionEnd : now).plusMinutes(3); + return ttl.isNegative() || ttl.isZero() ? Duration.ofMinutes(3) : ttl; + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/utils/WebSocketUtils.java b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/utils/WebSocketUtils.java new file mode 100644 index 0000000..5953c28 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/attendance/socket/utils/WebSocketUtils.java @@ -0,0 +1,8 @@ +package com.example.emergencyassistb4b4.domain.attendance.socket.utils; + +public class WebSocketUtils { + // 위도, 경도가 올바른지 판별 + public static boolean isValidCoordinates(double lat, double lon) { + return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/controller/KakaoMapController.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/controller/KakaoMapController.java index 99b23c6..d1ce2e6 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/controller/KakaoMapController.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/controller/KakaoMapController.java @@ -19,7 +19,6 @@ @RequiredArgsConstructor public class KakaoMapController { - private final KakaoMapService kakaoMapService; // 쿼리 파라미터로 위도, 경도, 반경을 받아 대피소 목록 조회 @@ -29,8 +28,8 @@ public ResponseEntity>> getNearbyShelters( @RequestParam double longitude, @RequestParam(defaultValue = "1000") double radiusMeter) { - List shelterResponseDtoList =kakaoMapService.searchShelters(latitude, longitude, radiusMeter); - + List shelterResponseDtoList =kakaoMapService.searchShelters( + latitude, longitude, radiusMeter); return ApiResponse.onSuccess(SHELTER_SEARCH_SUCCESS, shelterResponseDtoList); } @@ -42,9 +41,7 @@ public ResponseEntity>> getDisasterSummary( @RequestParam(defaultValue = "1000") int radiusMeter) { List summary = kakaoMapService.getDisasterSummary( - latitude, longitude, radiusMeter - ); - + latitude, longitude, radiusMeter); return ApiResponse.onSuccess(DISASTER_SEARCH_SUCCESS, summary); } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/controller/LocationController.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/controller/LocationController.java index f94f758..b4aeede 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/controller/LocationController.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/controller/LocationController.java @@ -4,7 +4,6 @@ import com.example.emergencyassistb4b4.domain.location.dto.request.RegionRequestDto; import com.example.emergencyassistb4b4.domain.location.service.LocationService; import com.example.emergencyassistb4b4.global.security.auth.CustomUserDetails; -import com.example.emergencyassistb4b4.domain.user.domain.User; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -12,8 +11,6 @@ import static com.example.emergencyassistb4b4.global.status.SuccessStatus.LOCATION_SAVE_SUCCESS; -// 백그라운드 존재, 입력받을 창구로써 역할 -> return 값이 Void로 변경가능성 존재 -// 일단 JWT에서 userId 가져올 예정 @RestController @RequestMapping("/location") @RequiredArgsConstructor @@ -21,16 +18,12 @@ public class LocationController { private final LocationService locationService; - // 모든 유저 저장 + // 유저의 시,구 정보를 저장 @PostMapping("/region") public ResponseEntity> saveRegion(@RequestBody RegionRequestDto dto, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - //추후 변경 예정 - User currentUser = userDetails.getUser(); - - locationService.saveRegion(currentUser.getId(), dto.getProvince(), dto.getCity()); + @AuthenticationPrincipal CustomUserDetails userDetails){ + locationService.saveRegion(userDetails.getUser().getId(), dto.getProvince(), dto.getCity()); return ApiResponse.onSuccess(LOCATION_SAVE_SUCCESS,null); } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/request/CoordinateRequestDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/request/CoordinateRequestDto.java deleted file mode 100644 index d1ad0c1..0000000 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/request/CoordinateRequestDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.emergencyassistb4b4.domain.location.dto.request; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@AllArgsConstructor -@NoArgsConstructor -public class CoordinateRequestDto { - // userId는 삭제예정 - private Long userId; - private double latitude; - private double longitude; -} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/request/RegionRequestDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/request/RegionRequestDto.java index ee093fc..7979685 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/request/RegionRequestDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/request/RegionRequestDto.java @@ -10,5 +10,6 @@ public class RegionRequestDto { private String province; + private String city; } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterReportMapper.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterReportMapper.java index 143e1b7..ef81c17 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterReportMapper.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterReportMapper.java @@ -7,6 +7,7 @@ public class DisasterReportMapper { + // JPA Native Query 결과(Object[] 형태)를 DisasterReportSimpleDto 리스트로 변환 public static List map(List rawResults) { return rawResults.stream().map(row -> { String disasterTypeStr = (String) row[0]; @@ -22,4 +23,4 @@ public static List map(List rawResults) { ); }).toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterReportSimpleDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterReportSimpleDto.java index d68889e..6b7bf11 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterReportSimpleDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterReportSimpleDto.java @@ -8,9 +8,13 @@ @Getter @AllArgsConstructor public class DisasterReportSimpleDto { + private DisasterType disasterType; + private ReportStatus status; + private double latitude; + private double longitude; } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterSummaryDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterSummaryDto.java index 1ef4d0e..cf4c484 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterSummaryDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/DisasterSummaryDto.java @@ -12,9 +12,13 @@ public class DisasterSummaryDto { private DisasterType disasterType; + private ReportStatus status; - private long count; // 해당 유형 + 상태 재난 신고 건수 + + private long count; + private double latitude; + private double longitude; } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/ShelterResponseDto.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/ShelterResponseDto.java index 5e18024..b15d012 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/ShelterResponseDto.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/dto/response/ShelterResponseDto.java @@ -14,6 +14,7 @@ public class ShelterResponseDto { private double latitude; private double longitude; + // Kakao Map API 응답(JsonNode)에서 대피소 정보를 추출해 DTO로 변환 public static ShelterResponseDto from(JsonNode doc) { return ShelterResponseDto.builder() .name(doc.path("place_name").asText()) @@ -23,4 +24,3 @@ public static ShelterResponseDto from(JsonNode doc) { .build(); } } - diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/redis/LocationRedisKeyUtil.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/redis/LocationRedisKeyUtil.java new file mode 100644 index 0000000..a4e3ec0 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/redis/LocationRedisKeyUtil.java @@ -0,0 +1,24 @@ +package com.example.emergencyassistb4b4.domain.location.redis; + + +public class LocationRedisKeyUtil { + + private static final String REGION_PREFIX = "region:"; + private static final String USER_LOCATIONS = "user:locations"; + private static final String LOCATION_TTL_PREFIX = "location:ttl:"; + + // 특정 지역 key 생성 + public static String regionKey(String province, String city) { + return REGION_PREFIX + province + ":" + city; + } + + // 좌표 저장 key + public static String userLocationsKey() { + return USER_LOCATIONS; + } + + // 좌표 TTL key + public static String locationTtlKey(Long userId) { + return LOCATION_TTL_PREFIX + userId; + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/redis/LocationRepository.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/redis/LocationRepository.java index 5025835..e233a6a 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/redis/LocationRepository.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/redis/LocationRepository.java @@ -1,21 +1,12 @@ package com.example.emergencyassistb4b4.domain.location.redis; import lombok.RequiredArgsConstructor; -import org.springframework.data.geo.Circle; -import org.springframework.data.geo.Distance; -import org.springframework.data.geo.GeoResults; -import org.springframework.data.geo.Point; -import org.springframework.data.redis.connection.RedisGeoCommands; +import org.springframework.data.geo.*; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.domain.geo.Metrics; import org.springframework.stereotype.Repository; import java.time.Duration; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; +import java.util.*; @Repository @RequiredArgsConstructor @@ -23,49 +14,24 @@ public class LocationRepository { private final RedisTemplate redisTemplate; - // 기기 토큰 변경예정 - public void saveRegion(Long userId, String province , String city) { - String regionKey = "region:" + province + ":" + city; + // 유저 지역 정보 저장 (5분 TTL) + public void saveRegion(Long userId, String province, String city) { + String regionKey = LocationRedisKeyUtil.regionKey(province, city); redisTemplate.opsForSet().add(regionKey, userId.toString()); redisTemplate.expire(regionKey, Duration.ofMinutes(5)); } + // 특정 지역의 유저 목록 조회 public Set getRegionUsers(String regionKey) { return redisTemplate.opsForSet().members(regionKey); } + // 좌표 저장 + TTL(1분) 부여 public void saveCoordinates(Long userId, double latitude, double longitude) { - String key = "user:locations"; + String key = LocationRedisKeyUtil.userLocationsKey(); redisTemplate.opsForGeo().add(key, new Point(longitude, latitude), userId.toString()); - String expireKey = "location:ttl:" + userId; + String expireKey = LocationRedisKeyUtil.locationTtlKey(userId); redisTemplate.opsForValue().set(expireKey, "1", Duration.ofMinutes(1)); } - - public Optional getCoordinates(String userId) { - String key = "user:locations"; - List positions = redisTemplate.opsForGeo().position(key, userId); - if (positions == null || positions.isEmpty() || positions.get(0) == null) { - return Optional.empty(); - } - return Optional.of(positions.get(0)); - } - - public List findUsersWithinRadius(double latitude, double longitude, int radiusMeters) { - String key = "user:locations"; // Redis GEO key - - // Redis는 (longitude, latitude) 순서임 - Point center = new Point(longitude, latitude); - Distance radius = new Distance(radiusMeters / 1000.0, Metrics.KILOMETERS); // m → km - Circle circle = new Circle(center, radius); - - GeoResults> results = - redisTemplate.opsForGeo().radius(key, circle); - - if (results == null) return Collections.emptyList(); - - return results.getContent().stream() - .map(r -> r.getContent().getName()) - .collect(Collectors.toList()); - } } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/service/LocationService.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/service/LocationService.java index 04b0122..b162530 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/location/service/LocationService.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/service/LocationService.java @@ -26,12 +26,4 @@ public void saveCoordinates(Long userId, double latitude, double longitude) { public Set getRegion(String region) { return locationRepository.getRegionUsers(region); } - - public Optional getCoordinates(Long userId) { - return locationRepository.getCoordinates(userId.toString()); - } - - public List findUsersWithinRadius(double latitude, double longitude, double radiusMeters) { - return locationRepository.findUsersWithinRadius(latitude, longitude, (int) radiusMeters); - } } \ No newline at end of file diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/location/service/ShelterService.java b/src/main/java/com/example/emergencyassistb4b4/domain/location/service/ShelterService.java new file mode 100644 index 0000000..e64e2bc --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/domain/location/service/ShelterService.java @@ -0,0 +1,63 @@ +package com.example.emergencyassistb4b4.domain.location.service; + +import com.example.emergencyassistb4b4.domain.location.dto.response.ShelterResponseDto; +import com.example.emergencyassistb4b4.domain.location.util.KakaoApiUtils; +import com.example.emergencyassistb4b4.global.exception.ApiException; +import com.example.emergencyassistb4b4.global.status.ErrorStatus; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ShelterService { + + @Value("${kakao.api.key}") + private String restApiKey; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + // 카카오 API를 호출하여 반경 내 대피소(PO3: 치안기관) 조회 + public List searchShelters(double latitude, double longitude, double radiusMeter) { + String categoryCode = "PO3"; // 치안기관 + String url = KakaoApiUtils.buildCategorySearchUrl(categoryCode, longitude, latitude, radiusMeter); + + HttpHeaders headers = KakaoApiUtils.createAuthHeader(restApiKey); + HttpEntity entity = new HttpEntity<>(headers); + + try { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + if (response.getStatusCode() != HttpStatus.OK) { + throw new ApiException(ErrorStatus.KAKAO_API_REQUEST_FAILED); + } + + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode documents = root.path("documents"); + + List shelters = new ArrayList<>(); + for (int i = 0; i < Math.min(3, documents.size()); i++) { + shelters.add(ShelterResponseDto.from(documents.get(i))); + } + + return shelters; + + } catch (JsonProcessingException e) { + throw new ApiException(ErrorStatus.KAKAO_API_RESPONSE_PARSE_FAILED); + } catch (RestClientException e) { + throw new ApiException(ErrorStatus.KAKAO_API_RESPONSE_STATUS_ERROR); + } + } +} diff --git a/src/main/java/com/example/emergencyassistb4b4/global/config/rabbitMQ/RabbitConfig.java b/src/main/java/com/example/emergencyassistb4b4/global/config/rabbitMQ/RabbitConfig.java index 828a336..05448ce 100644 --- a/src/main/java/com/example/emergencyassistb4b4/global/config/rabbitMQ/RabbitConfig.java +++ b/src/main/java/com/example/emergencyassistb4b4/global/config/rabbitMQ/RabbitConfig.java @@ -1,3 +1,4 @@ +// RabbitConfig.java package com.example.emergencyassistb4b4.global.config.rabbitMQ; import com.fasterxml.jackson.databind.ObjectMapper; @@ -23,48 +24,43 @@ @Configuration public class RabbitConfig { - // 지연 익스체인지, 큐, 라우팅키 - public static final String DELAYED_EXCHANGE_NAME = "tracking.delay.exchange"; - public static final String DELAYED_QUEUE_NAME = "tracking-delay-queue"; - public static final String DELAYED_ROUTING_KEY = "tracking.delay.routingkey"; - // 메시지 컨버터 @Bean public Jackson2JsonMessageConverter messageConverter() { ObjectMapper mapper = JsonMapper.builder() .addModule(new JavaTimeModule()) .build(); - mapper.activateDefaultTyping( - mapper.getPolymorphicTypeValidator(), - ObjectMapper.DefaultTyping.NON_FINAL - ); return new Jackson2JsonMessageConverter(mapper); } // RabbitTemplate (Producer 재시도) @Bean - public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, + Jackson2JsonMessageConverter messageConverter) { RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); rabbitTemplate.setRetryTemplate(new RetryTemplateBuilder() .maxAttempts(3) .fixedBackoff(1000) .build()); - rabbitTemplate.setMessageConverter(messageConverter()); + rabbitTemplate.setMessageConverter(messageConverter); return rabbitTemplate; } - // 지연 큐 - @Bean + // 지연 큐 + Dead Letter 연결 + @Bean("trackingDelayQueue") public Queue trackingDelayQueue() { - return new Queue(DELAYED_QUEUE_NAME, true); + return QueueBuilder.durable(RabbitMQConstant.DELAYED_QUEUE_NAME) + .withArgument("x-dead-letter-exchange", RabbitMQConstant.DEAD_LETTER_EXCHANGE_NAME) + .withArgument("x-dead-letter-routing-key", RabbitMQConstant.DEAD_LETTER_ROUTING_KEY) + .build(); } // 지연 익스체인지 - @Bean + @Bean("trackingDelayExchange") public CustomExchange trackingDelayExchange() { Map args = new HashMap<>(); args.put("x-delayed-type", "direct"); - return new CustomExchange(DELAYED_EXCHANGE_NAME, "x-delayed-message", true, false, args); + return new CustomExchange(RabbitMQConstant.DELAYED_EXCHANGE_NAME, "x-delayed-message", true, false, args); } // 바인딩 @@ -72,26 +68,26 @@ public CustomExchange trackingDelayExchange() { public Binding trackingDelayBinding() { return BindingBuilder.bind(trackingDelayQueue()) .to(trackingDelayExchange()) - .with(DELAYED_ROUTING_KEY) + .with(RabbitMQConstant.DELAYED_ROUTING_KEY) .noargs(); } - // 데드레터 큐 - @Bean + // Dead Letter 큐 + @Bean("trackingDeadLetterQueue") public Queue trackingDeadLetterQueue() { - return QueueBuilder.durable("tracking-dead-letter-queue").build(); + return QueueBuilder.durable(RabbitMQConstant.DEAD_LETTER_QUEUE_NAME).build(); } - @Bean + @Bean("trackingDeadLetterExchange") public Exchange trackingDeadLetterExchange() { - return ExchangeBuilder.topicExchange("tracking-dlx").durable(true).build(); + return ExchangeBuilder.topicExchange(RabbitMQConstant.DEAD_LETTER_EXCHANGE_NAME).durable(true).build(); } @Bean public Binding trackingDeadLetterBinding() { return BindingBuilder.bind(trackingDeadLetterQueue()) .to(trackingDeadLetterExchange()) - .with("tracking.session.dead") + .with(RabbitMQConstant.DEAD_LETTER_ROUTING_KEY) .noargs(); } @@ -108,17 +104,19 @@ public ApplicationRunner rabbitAdminInitializer(RabbitAdmin rabbitAdmin) { // Listener Container Factory (Consumer Ack/Nack + 재시도) @Bean - public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory, - Jackson2JsonMessageConverter messageConverter) { + public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory( + ConnectionFactory connectionFactory, + Jackson2JsonMessageConverter messageConverter + ) { SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); factory.setMessageConverter(messageConverter); factory.setAcknowledgeMode(AcknowledgeMode.MANUAL); // 수동 Ack/Nack - factory.setPrefetchCount(10); // 한 번에 처리할 메시지 수 + factory.setPrefetchCount(10); factory.setAdviceChain(RetryInterceptorBuilder.stateless() .maxAttempts(3) - .backOffOptions(1000, 2.0, 10000) // 초기 1초, multiplier 2, max 10초 - .recoverer(new RejectAndDontRequeueRecoverer()) // 재시도 후 실패 시 처리 + .backOffOptions(1000, 2.0, 10000) + .recoverer(new RejectAndDontRequeueRecoverer()) .build()); return factory; } diff --git a/src/main/java/com/example/emergencyassistb4b4/global/config/rabbitMQ/RabbitMQConstant.java b/src/main/java/com/example/emergencyassistb4b4/global/config/rabbitMQ/RabbitMQConstant.java new file mode 100644 index 0000000..bd73054 --- /dev/null +++ b/src/main/java/com/example/emergencyassistb4b4/global/config/rabbitMQ/RabbitMQConstant.java @@ -0,0 +1,13 @@ +package com.example.emergencyassistb4b4.global.config.rabbitMQ; + +public class RabbitMQConstant { + // 지연 익스체인지, 큐, 라우팅키 + public static final String DELAYED_EXCHANGE_NAME = "tracking.delay.exchange"; + public static final String DELAYED_QUEUE_NAME = "tracking-delay-queue"; + public static final String DELAYED_ROUTING_KEY = "tracking.delay.routingkey"; + + // 데드레터 큐 + public static final String DEAD_LETTER_QUEUE_NAME = "tracking-dead-letter-queue"; + public static final String DEAD_LETTER_EXCHANGE_NAME = "tracking-dlx"; + public static final String DEAD_LETTER_ROUTING_KEY = "tracking.session.dead"; +} diff --git a/src/main/java/com/example/emergencyassistb4b4/global/config/schedulerConfig/SchedulerConfig.java b/src/main/java/com/example/emergencyassistb4b4/global/config/schedulerConfig/SchedulerConfig.java index 894f3a2..186560d 100644 --- a/src/main/java/com/example/emergencyassistb4b4/global/config/schedulerConfig/SchedulerConfig.java +++ b/src/main/java/com/example/emergencyassistb4b4/global/config/schedulerConfig/SchedulerConfig.java @@ -8,6 +8,9 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + @Configuration @EnableScheduling public class SchedulerConfig implements SchedulingConfigurer { @@ -29,6 +32,11 @@ public void configureTasks(ScheduledTaskRegistrar reg) { reg.setTaskScheduler(customTaskScheduler()); } + + @Bean + public ScheduledExecutorService scheduledExecutorService() { + return Executors.newSingleThreadScheduledExecutor(); + } } diff --git a/src/main/java/com/example/emergencyassistb4b4/global/config/webSocket/JwtHandshakeInterceptor.java b/src/main/java/com/example/emergencyassistb4b4/global/config/webSocket/JwtHandshakeInterceptor.java index 3c4a30a..fb27793 100644 --- a/src/main/java/com/example/emergencyassistb4b4/global/config/webSocket/JwtHandshakeInterceptor.java +++ b/src/main/java/com/example/emergencyassistb4b4/global/config/webSocket/JwtHandshakeInterceptor.java @@ -1,7 +1,6 @@ package com.example.emergencyassistb4b4.global.config.webSocket; import com.example.emergencyassistb4b4.global.security.jwt.JwtUtils; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.security.core.Authentication; @@ -11,18 +10,19 @@ import org.springframework.web.socket.server.HandshakeInterceptor; import org.springframework.web.util.UriComponentsBuilder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + import java.net.URI; import java.util.Map; @Slf4j +@RequiredArgsConstructor public class JwtHandshakeInterceptor implements HandshakeInterceptor { private final JwtUtils jwtUtils; - public JwtHandshakeInterceptor(JwtUtils jwtUtils) { - this.jwtUtils = jwtUtils; - } - + // WebSocket 연결 전에 JWT 토큰을 검증하고 인증 정보를 설정 @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) { @@ -32,14 +32,10 @@ public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse res String token = queryParams.getFirst("token"); if (token == null || token.isEmpty()) { - log.warn("WebSocket 연결 거부: token 파라미터가 없음 또는 비어있음"); + log.warn("WebSocket 연결 거부: token 파라미터 없음 또는 비어있음"); return false; } - // 토큰 일부 마스킹 (앞 6글자 + 뒤 6글자) - String maskedToken = token.length() > 12 ? token.substring(0, 6) + "..." + token.substring(token.length() - 6) : token; - log.info("WebSocket 핸드셰이크 token: {}", maskedToken); - if (jwtUtils.validateToken(token)) { Authentication authentication = jwtUtils.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); @@ -47,9 +43,8 @@ public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse res attributes.put("authentication", authentication); Long userId = jwtUtils.getUserId(token); attributes.put("userId", userId); - attributes.put("token", token); // 추가 + attributes.put("token", token); - log.info("WebSocket 연결 허용 - 사용자 ID: {}", userId); return true; } else { log.warn("WebSocket 연결 거부: 토큰 검증 실패"); @@ -60,10 +55,10 @@ public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse res return false; } - + // WebSocket 연결 후 처리 (필요 시 구현) @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { - // 필요 시 후처리 + // 후처리 목적 메서드 } } From 01d2ba4ea4329ea3da153afe52a37355e7034920 Mon Sep 17 00:00:00 2001 From: rabitis99 Date: Tue, 23 Sep 2025 17:31:10 +0900 Subject: [PATCH 8/8] =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alert/redis/RedisThresholdCounter.java | 36 +++++++++---------- .../volunteer/dto/Post/UpdatePostRequest.java | 2 -- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/alert/redis/RedisThresholdCounter.java b/src/main/java/com/example/emergencyassistb4b4/domain/alert/redis/RedisThresholdCounter.java index 5514b4b..6f06ddd 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/alert/redis/RedisThresholdCounter.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/alert/redis/RedisThresholdCounter.java @@ -25,26 +25,26 @@ public class RedisThresholdCounter { static { LUA_SCRIPT = new DefaultRedisScript<>(); LUA_SCRIPT.setScriptText( - """ - local count = redis.call('INCR', KEYS[1]) - redis.call('EXPIRE', KEYS[1], toNumber(ARGV[1])) - - for i = 2, #ARGV do - local threshold = toNumber(ARGV[i]) - if count == threshold then - local notifyKey = KEYS[2] .. ":" .. threshold - local set = redis.call('SETNX', notifyKey, "true") - if set == 1 then - redis.call('EXPIRE', notifyKey, toNumber(ARGV[1])) - return threshold - else - return -1 + """ + local count = redis.call('INCR', KEYS[1]) + redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1])) + + for i = 2, #ARGV do + local threshold = tonumber(ARGV[i]) + if count == threshold then + local notifyKey = KEYS[2] .. ":" .. threshold + local set = redis.call('SETNX', notifyKey, "true") + if set == 1 then + redis.call('EXPIRE', notifyKey, tonumber(ARGV[1])) + return threshold + else + return -1 + end end end - end - - return -1 - """ + + return -1 + """ ); LUA_SCRIPT.setResultType(Long.class); } diff --git a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UpdatePostRequest.java b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UpdatePostRequest.java index 1ebc68a..b33ac3e 100644 --- a/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UpdatePostRequest.java +++ b/src/main/java/com/example/emergencyassistb4b4/domain/volunteer/dto/Post/UpdatePostRequest.java @@ -2,7 +2,6 @@ import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common.PostAttendancePolicyDto; import com.example.emergencyassistb4b4.domain.volunteer.dto.Post.common.PostLocationDto; -import com.example.emergencyassistb4b4.domain.volunteer.dto.validator.ValidAttendancePolicy; import com.example.emergencyassistb4b4.domain.volunteer.enums.PostStatus; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -18,7 +17,6 @@ @NoArgsConstructor @AllArgsConstructor @Builder -@ValidAttendancePolicy public class UpdatePostRequest { @NotBlank(message = "제목은 필수입니다.")