diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/zoom/events/ZoomAttendanceEventListener.java b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/events/ZoomAttendanceEventListener.java new file mode 100644 index 00000000..582f10f1 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/events/ZoomAttendanceEventListener.java @@ -0,0 +1,146 @@ +package backend.techeerzip.domain.zoom.events; + +import java.time.Instant; + +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.stereotype.Component; + +import backend.techeerzip.domain.zoom.service.ZoomAttendanceService; +import backend.techeerzip.infra.redis.service.RedisService; +import backend.techeerzip.infra.zoom.dto.ZoomAttendanceEvents; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent.WebhookParticipant; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ZoomAttendanceEventListener { + private final RedisService redisService; + private final TaskScheduler scheduler; + private final ZoomAttendanceService zoomAttendanceService; + + // 퇴장 이벤트 (줌 나가기 / 회의실 이동) + @EventListener + public void handleLeftAttempt(ZoomAttendanceEvents.ParticipantLeftAttempt attempt) { + WebhookParticipant participant = attempt.participant(); + String uuid = participant.getParticipantUuid(); + String userName = participant.getUserName(); + + // uuid null 체크 (필수) + if (uuid == null || uuid.isBlank()) { + log.warn("[ZOOM] [LEFT] [SKIP] participantUuid가 null 또는 빈 값 - userName={}", userName); + return; + } + + String uuidShort = uuid.substring(0, Math.min(8, uuid.length())); + + // 이번 퇴장 시도를 식별할 고유 토큰 생성 (타임스탬프 사용) + String myToken = String.valueOf(System.currentTimeMillis()); + + // Redis에 토큰 저장 (덮어쓰기 허용 - 가장 최신 퇴장이 중요하므로) + // TTL을 12초로 설정하여 스케줄러 실행 시점(10초 후)에 키가 확실히 존재하도록 함 + redisService.setZoomPendingLeave(uuid, myToken, 12L); + log.info( + "[ZOOM] [LEFT] [PENDING] userName={} uuid={} token={} delay=10s", + userName, + uuidShort, + myToken); + + // 스케줄러 등록 (myToken을 람다 안으로 캡처) + scheduler.schedule( + () -> verifyFinalLeave(participant, myToken, uuidShort, userName), + Instant.now().plusSeconds(10)); + } + + /** 10초 뒤 실행될 검증 로직 (토큰 비교) */ + private void verifyFinalLeave( + WebhookParticipant participant, String myToken, String uuidShort, String userName) { + String uuid = participant.getParticipantUuid(); + + // uuid null 체크 (방어적 체크) + if (uuid == null || uuid.isBlank()) { + log.warn( + "[ZOOM] [LEFT] [SKIP] 스케줄러 실행 시 participantUuid가 null - userName={}", userName); + return; + } + + // Redis에서 현재 저장된 토큰 조회 + String currentToken = redisService.getZoomPendingLeaveToken(uuid); + + // Case 1: 키가 없다? -> Join이 와서 지웠음 -> 생존 + if (currentToken == null) { + log.info( + "[ZOOM] [LEFT] [CANCELLED] 사용자 복귀 (키 없음) - userName={} uuid={}", + userName, + uuidShort); + return; + } + + // Case 2: 키는 있는데 토큰이 다르다? -> 내가 예약한 뒤에 또 다른 Left가 와서 덮어씀 -> 생존 (다음 스케줄러에 위임) + if (!myToken.equals(currentToken)) { + log.info( + "[ZOOM] [LEFT] [SKIP] 더 최신의 퇴장 이벤트 발생 (내 토큰: {}, 현재 토큰: {}) - userName={} uuid={}", + myToken, + currentToken, + userName, + uuidShort); + return; + } + + // Case 3: 토큰이 일치한다 -> 10초 동안 Join도 없었고 새로운 Left도 없었음 -> 진짜 퇴장 + log.info("[ZOOM] [LEFT] [CONFIRMED] 세션 종료 처리 - userName={} uuid={}", userName, uuidShort); + + zoomAttendanceService.handleParticipantLeft(participant); // DB Update + redisService.deleteZoomPendingLeave(uuid); // 정리 + } + + // 입장 이벤트 (줌 접속 / 회의실 이동) + @EventListener + public void handleJoined(ZoomAttendanceEvents.ParticipantJoined joined) { + WebhookParticipant participant = joined.participant(); + String uuid = participant.getParticipantUuid(); + String userName = participant.getUserName(); + + // uuid null 체크 (필수) + if (uuid == null || uuid.isBlank()) { + log.warn("[ZOOM] [JOIN] [SKIP] participantUuid가 null 또는 빈 값 - userName={}", userName); + return; + } + + String uuidShort = uuid.substring(0, Math.min(8, uuid.length())); + + // 보류 상태면 보류 삭제 (연속 접속으로 간주) + if (redisService.hasZoomPendingLeave(uuid)) { + log.info( + "[ZOOM] [JOIN] [SESSION_KEPT] 연속 접속으로 간주, DB 기록 안함 - userName={} uuid={}", + userName, + uuidShort); + redisService.deleteZoomPendingLeave(uuid); + return; + } + + // Race Condition 방지: 이미 다른 스레드에서 입장 처리 중인지 확인 + boolean isNewJoining = redisService.setZoomJoining(uuid, 30L); // 30초 TTL + if (!isNewJoining) { + log.debug( + "[ZOOM] [JOIN] [SKIP] 이미 처리 중인 입장 이벤트 - userName={} uuid={}", + userName, + uuidShort); + return; + } + + try { + // 새 세션 시작 + log.info( + "[ZOOM] [JOIN] [NEW_SESSION] 새 세션 시작 - userName={} uuid={}", + userName, + uuidShort); + zoomAttendanceService.handleParticipantJoined(participant); + } finally { + // 처리 완료 후 Redis 키 삭제 + redisService.deleteZoomJoining(uuid); + } + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceService.java b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceService.java index 11b83798..e93c6b0e 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceService.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceService.java @@ -27,11 +27,15 @@ public class ZoomAttendanceService { public void handleParticipantJoined(WebhookParticipant participant) { try { String participantUuid = participant.getParticipantUuid(); + String uuidShort = participantUuid != null ? participantUuid.substring(0, 8) : "N/A"; String userName = participant.getUserName(); String joinTimeStr = participant.getJoinTime(); if (joinTimeStr == null) { - log.warn("joinTimeStr 정보가 누락되었습니다. uuid: {}", participantUuid); + log.warn( + "[ZOOM] [JOIN] [DB] [WARN] joinTime 누락 - userName={} uuid={}", + userName, + uuidShort); return; } @@ -39,24 +43,21 @@ public void handleParticipantJoined(WebhookParticipant participant) { LocalDateTime joinTime = parseZoomDateTime(joinTimeStr); LocalDate meetingDate = joinTime.toLocalDate(); - // 활성 세션 조회 (leaveTime이 null인 기록) + // 활성 세션 조회 (소회의실 이동과 줌 나가는 이벤트가 동일해서 leaveTime이 존재할 수 있음) // participant_uuid는 링크 접속 시 생성되고, 완전히 나갔다 다시 들어오면 새로 할당됨 - // 따라서 같은 participant_uuid로 활성 세션이 있으면 = 소회의실에서 메인으로 복귀한 경우 + // 따라서 같은 participant_uuid로 활성 세션이 있으면 소회의실, 메인 간 이동한 상황으로 관리 Optional activeSession = zoomAttendanceRepository.findActiveSessionByParticipantUuid(participantUuid); if (activeSession.isPresent()) { log.debug( - "활성 세션이 존재합니다. participantUuid: {}, date: {} (소회의실에서 메인으로 복귀 무시)", - participantUuid, + "[ZOOM] [JOIN] [DB] [SKIP] 활성 세션 존재 (소회의실→메인 복귀) - userName={} uuid={} date={}", + userName, + uuidShort, meetingDate); return; // 활성 세션이 있으면 무시 (소회의실에서 메인으로 복귀한 경우) } - // 완전히 나갔다 다시 들어온 경우 (새로운 participant_uuid) 또는 첫 입장 - // 기존에 완료된 세션이 있는지 확인 (같은 날짜에 다른 participant_uuid로 기록이 있을 수 있음) - // 하지만 participant_uuid가 다르면 새로운 세션이므로 별도 엔티티로 저장 - // 새로운 출석 기록 생성 (입장 시에는 leaveTime과 durationMinutes는 null) ZoomAttendance attendance = ZoomAttendance.builder() @@ -71,13 +72,20 @@ public void handleParticipantJoined(WebhookParticipant participant) { zoomAttendanceRepository.save(attendance); log.info( - "새로운 출석 기록 저장: {} (uuid: {}, 시간: {})", + "[ZOOM] [JOIN] [DB] [SAVED] 출석 기록 저장 완료 - userName={} uuid={} joinTime={}", userName, - participantUuid.substring(0, 8) + "...", + uuidShort, joinTime); } catch (Exception e) { - log.error("참가자 입장 처리 중 오류: {}", e.getMessage(), e); + log.error( + "[ZOOM] [JOIN] [DB] [ERROR] 입장 처리 실패 - userName={} uuid={} error={}", + participant.getUserName(), + participant.getParticipantUuid() != null + ? participant.getParticipantUuid().substring(0, 8) + : "N/A", + e.getMessage(), + e); } } @@ -85,19 +93,31 @@ public void handleParticipantJoined(WebhookParticipant participant) { public void handleParticipantLeft(WebhookParticipant participant) { try { String participantUuid = participant.getParticipantUuid(); + String uuidShort = participantUuid != null ? participantUuid.substring(0, 8) : "N/A"; + String userName = participant.getUserName(); String leaveReason = participant.getLeaveReason(); String leaveTimeStr = participant.getLeaveTime(); if (leaveTimeStr == null) { - log.warn("leaveTimeStr 정보가 누락되었습니다. uuid: {}", participantUuid); + log.warn( + "[ZOOM] [LEFT] [DB] [WARN] leaveTime 누락 - userName={} uuid={}", + userName, + uuidShort); return; } + log.debug( + "[ZOOM] [LEFT] [DB] [DETAIL] leaveReason 확인 - userName={} uuid={} reason={}", + userName, + uuidShort, + leaveReason); + // 완전히 나간 케이스만 처리 (소회의실 이동, 대기실 관련 등은 제외) if (!ZoomLeaveReason.isCompleteExit(leaveReason)) { log.debug( - "완전히 나간 케이스가 아닙니다. participantUuid: {}, leaveReason: {} (무시)", - participantUuid, + "[ZOOM] [LEFT] [DB] [SKIP] 완전 퇴장 아님 (무시) - userName={} uuid={} reason={}", + userName, + uuidShort, leaveReason); return; } @@ -105,33 +125,41 @@ public void handleParticipantLeft(WebhookParticipant participant) { // 시간 파싱 LocalDateTime leaveTime = parseZoomDateTime(leaveTimeStr); - // 활성 세션 조회 (leaveTime이 null인 기록만) + // 활성 세션 조회 (소회의실 이동과 줌 나가는 이벤트가 동일해서 leaveTime이 존재할 수 있음) // participantUuid만으로 조회하여 날짜 경계 문제 해결 (밤 11시 입장 → 다음 날 새벽 퇴장 케이스) Optional activeSession = zoomAttendanceRepository.findActiveSessionByParticipantUuid(participantUuid); if (activeSession.isEmpty()) { - log.debug( - "활성 세션을 찾을 수 없습니다. participantUuid: {} (이미 완료된 세션이거나 소회의실 퇴장)", - participantUuid); - return; // 활성 세션이 없으면 무시 (이미 완료된 세션이거나 소회의실 퇴장) + log.warn( + "[ZOOM] [LEFT] [DB] [WARN] 활성 세션 없음 (이미 완료 또는 소회의실 퇴장) - userName={} uuid={}", + userName, + uuidShort); + return; // 활성 세션이 없으면 무시 } - // 활성 세션의 퇴장 시간 업데이트 (완전히 나간 경우) + // 활성 세션의 퇴장 시간 업데이트 ZoomAttendance attendance = activeSession.get(); attendance.updateLeaveTime(leaveTime); zoomAttendanceRepository.save(attendance); log.info( - "출석 기록 업데이트: {} (uuid: {}, 퇴장시간: {}, 머무른시간: {}분)", + "[ZOOM] [LEFT] [DB] [UPDATED] 출석 기록 업데이트 완료 - userName={} uuid={} leaveTime={} duration={}min", attendance.getUserName(), - participantUuid.substring(0, 8) + "...", + uuidShort, leaveTime, attendance.getDurationMinutes()); } catch (Exception e) { - log.error("참가자 퇴장 처리 중 오류: {}", e.getMessage(), e); + log.error( + "[ZOOM] [LEFT] [DB] [ERROR] 퇴장 처리 실패 - userName={} uuid={} error={}", + participant.getUserName(), + participant.getParticipantUuid() != null + ? participant.getParticipantUuid().substring(0, 8) + : "N/A", + e.getMessage(), + e); } } @@ -144,7 +172,11 @@ private LocalDateTime parseZoomDateTime(String dateTimeStr) { } return LocalDateTime.parse(dateTimeStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME); } catch (Exception e) { - log.error("시간 파싱 오류: {}", dateTimeStr, e); + log.error( + "[ZOOM] [PARSE] [ERROR] 시간 파싱 실패 - dateTime={} error={}", + dateTimeStr, + e.getMessage(), + e); return LocalDateTime.now(); // fallback } } diff --git a/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java b/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java index 2357b235..600b6ef8 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java @@ -206,6 +206,8 @@ public enum ErrorCode { REDIS_CONNECTION_INIT_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "R005", "Redis 연결 초기화에 실패했습니다."), REDIS_TASK_SAVE_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "R006", "Redis 작업 상태 저장에 실패했습니다."), REDIS_TASK_READ_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "R007", "Redis 작업 상세 정보 조회에 실패했습니다."), + REDIS_INVALID_UUID( + HttpStatus.BAD_REQUEST, "R008", "유효하지 않은 UUID입니다. UUID는 null이거나 빈 값일 수 없습니다."), // Slack SLACK_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "SLACK_001", "슬랙 요청 전송 실패"), diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/redis/service/RedisService.java b/techeerzip/src/main/java/backend/techeerzip/infra/redis/service/RedisService.java index 41fab32b..3c6e865f 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/redis/service/RedisService.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/redis/service/RedisService.java @@ -1,5 +1,6 @@ package backend.techeerzip.infra.redis.service; +import java.time.Duration; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -9,19 +10,20 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; -import backend.techeerzip.global.logger.CustomLogger; import backend.techeerzip.infra.redis.exception.RedisConnectionInitException; import backend.techeerzip.infra.redis.exception.RedisDeleteException; +import backend.techeerzip.infra.redis.exception.RedisInvalidUuidException; import backend.techeerzip.infra.redis.exception.RedisTaskReadException; import backend.techeerzip.infra.redis.exception.RedisTaskSaveException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class RedisService { private static final String CONTEXT = "RedisService"; private final RedisTemplate redisTemplate; - private final CustomLogger logger; @PostConstruct public void init() { @@ -31,9 +33,9 @@ public void init() { throw new IllegalStateException("Redis 연결 팩토리가 올바르게 초기화되지 않았습니다."); } redisTemplate.getConnectionFactory().getConnection().ping(); - logger.info("Redis 연결 성공", CONTEXT); + log.info("Redis 연결 성공: {}", CONTEXT); } catch (Exception e) { - logger.error("Redis 연결 실패: {}", e.getMessage()); + log.error("Redis 연결 실패: {}", e.getMessage()); throw new RedisConnectionInitException(e); } } @@ -50,9 +52,9 @@ public void setTaskStatus(String taskId, String task) { "result", "pending", "processed", "false")); redisTemplate.expire(taskId, 3600, TimeUnit.SECONDS); // expire 1시간 = 3600초 - logger.info("작업 상태 설정 완료 - taskId: {}", taskId, CONTEXT); + log.info("작업 상태 설정 완료 - taskId: {}, context: {}", taskId, CONTEXT); } catch (Exception e) { - logger.error("작업 상태 설정 실패 - taskId: {}, 오류: {}", taskId, e.getMessage()); + log.error("작업 상태 설정 실패 - taskId: {}, 오류: {}", taskId, e.getMessage()); throw new RedisTaskSaveException(e); } } @@ -68,9 +70,9 @@ public void setTaskStatusWithUserId(String taskId, Long userId) { redisTemplate.opsForHash().putAll(taskId, taskData); redisTemplate.expire(taskId, 36000, TimeUnit.SECONDS); // expire 10시간 = 36000초 - logger.info("작업 초기 상태 설정 완료 - taskId: {}, type: {}", taskId, CONTEXT); + log.info("작업 초기 상태 설정 완료 - taskId: {}, type: {}", taskId, CONTEXT); } catch (Exception e) { - logger.error("작업 초기 상태 설정 실패 - taskId: {}, 오류: {}", taskId, e.getMessage(), e); + log.error("작업 초기 상태 설정 실패 - taskId: {}, 오류: {}", taskId, e.getMessage(), e); throw new RedisTaskSaveException(e); } } @@ -79,26 +81,108 @@ public void setTaskStatusWithUserId(String taskId, Long userId) { public Map getTaskDetails(String taskId) { try { Map details = redisTemplate.opsForHash().entries(taskId); - logger.info("작업 상세 정보 조회 완료 - taskId: {}", taskId, CONTEXT); + log.info("작업 상세 정보 조회 완료 - taskId: {}, context: {}", taskId, CONTEXT); return details; } catch (Exception e) { - logger.error("작업 상세 정보 조회 실패 - taskId: {}, 오류: {}", taskId, e.getMessage()); + log.error("작업 상세 정보 조회 실패 - taskId: {}, 오류: {}", taskId, e.getMessage()); throw new RedisTaskReadException(e); } } /** 완료된 작업 삭제 */ - public void deleteTask(String taskId) { + public boolean deleteTask(String taskId) { try { - Boolean deleted = redisTemplate.delete(taskId); - if (Boolean.FALSE.equals(deleted)) { - logger.error("작업 삭제 실패 - taskId: {}, 작업을 찾을 수 없음", taskId, CONTEXT); + boolean deleted = redisTemplate.delete(taskId); + if (!deleted) { + log.error("작업 삭제 실패 - taskId: {}, context: {}", taskId, CONTEXT); } else { - logger.info("작업 삭제 성공 - taskId: {}", taskId, CONTEXT); + log.info("작업 삭제 성공 - taskId: {}, context: {}", taskId, CONTEXT); } + return deleted; } catch (Exception e) { - logger.error("작업 삭제 실패 - taskId: {}, 오류: {}", taskId, e.getMessage(), CONTEXT); + log.error( + "작업 삭제 실패 - taskId: {}, 오류: {}, context: {}", taskId, e.getMessage(), CONTEXT); throw new RedisDeleteException(e); } } + + private static final String ZOOM_PENDING_PREFIX = "zoom:pending-"; + private static final String ZOOM_JOINING_PREFIX = "zoom:joining-"; + + /** 퇴장 대기 상태 설정 (토큰 저장, 덮어쓰기 허용) */ + public void setZoomPendingLeave(String uuid, String token, Long ttlSeconds) { + if (uuid == null || uuid.isBlank()) { + log.warn("setZoomPendingLeave: uuid가 null 또는 빈 값입니다. token={}", token); + throw new RedisInvalidUuidException(); + } + String key = setZoomPendingKey(uuid); + redisTemplate.opsForValue().set(key, token, Duration.ofSeconds(ttlSeconds)); + } + + /** 퇴장 대기 상태의 토큰 조회 */ + public String getZoomPendingLeaveToken(String uuid) { + if (uuid == null || uuid.isBlank()) { + log.warn("getZoomPendingLeaveToken: uuid가 null 또는 빈 값입니다."); + return null; + } + String key = setZoomPendingKey(uuid); + return redisTemplate.opsForValue().get(key); + } + + /** 퇴장 대기 상태 확인 (키 존재 여부) */ + public boolean hasZoomPendingLeave(String uuid) { + if (uuid == null || uuid.isBlank()) { + return false; + } + String key = setZoomPendingKey(uuid); + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + /** 퇴장 대기 상태 삭제 */ + public boolean deleteZoomPendingLeave(String uuid) { + if (uuid == null || uuid.isBlank()) { + log.warn("deleteZoomPendingLeave: uuid가 null 또는 빈 값입니다."); + return false; + } + String key = setZoomPendingKey(uuid); + return deleteTask(key); + } + + private static String setZoomPendingKey(String uuid) { + return ZOOM_PENDING_PREFIX + uuid; + } + + /** 입장 처리 중인지 확인 (Race Condition 방지) */ + public boolean setZoomJoining(String uuid, Long ttlSeconds) { + if (uuid == null || uuid.isBlank()) { + log.warn("setZoomJoining: uuid가 null 또는 빈 값입니다."); + return false; + } + String key = setZoomJoiningKey(uuid); + return Boolean.TRUE.equals( + redisTemplate + .opsForValue() + .setIfAbsent(key, "JOINING", Duration.ofSeconds(ttlSeconds))); + } + + public boolean hasZoomJoining(String uuid) { + if (uuid == null || uuid.isBlank()) { + return false; + } + String key = setZoomJoiningKey(uuid); + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + public boolean deleteZoomJoining(String uuid) { + if (uuid == null || uuid.isBlank()) { + log.warn("deleteZoomJoining: uuid가 null 또는 빈 값입니다."); + return false; + } + String key = setZoomJoiningKey(uuid); + return deleteTask(key); + } + + private static String setZoomJoiningKey(String uuid) { + return ZOOM_JOINING_PREFIX + uuid; + } } diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomAttendanceEvents.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomAttendanceEvents.java new file mode 100644 index 00000000..6f1e336e --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomAttendanceEvents.java @@ -0,0 +1,10 @@ +package backend.techeerzip.infra.zoom.dto; + +public class ZoomAttendanceEvents { + + public record ParticipantJoined( + ZoomWebhookEvent.WebhookParticipant participant, Long timestamp) {} + + public record ParticipantLeftAttempt( + ZoomWebhookEvent.WebhookParticipant participant, Long timestamp) {} +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomWebhookEventType.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomWebhookEventType.java index d609a5ee..5e937014 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomWebhookEventType.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomWebhookEventType.java @@ -6,7 +6,9 @@ public enum ZoomWebhookEventType { URL_VALIDATION("endpoint.url_validation"), PARTICIPANT_JOINED("meeting.participant_joined"), - PARTICIPANT_LEFT("meeting.participant_left"); + PARTICIPANT_LEFT("meeting.participant_left"), + PARTICIPANT_JOINED_BREAKOUT_ROOM("meeting.participant_joined_breakout_room"), + PARTICIPANT_LEFT_BREAKOUT_ROOM("meeting.participant_left_breakout_room"); private final String eventName; @@ -25,4 +27,12 @@ public static boolean isParticipantJoined(String eventName) { public static boolean isParticipantLeft(String eventName) { return PARTICIPANT_LEFT.getEventName().equals(eventName); } + + public static boolean isParticipantJoinedBreakoutRoom(String eventName) { + return PARTICIPANT_JOINED_BREAKOUT_ROOM.getEventName().equals(eventName); + } + + public static boolean isParticipantLeftBreakoutRoom(String eventName) { + return PARTICIPANT_LEFT_BREAKOUT_ROOM.getEventName().equals(eventName); + } } diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookController.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookController.java index 828be562..99409593 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookController.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookController.java @@ -25,7 +25,7 @@ public class ZoomWebhookController { public ResponseEntity handleWebhookEvent( @RequestBody ZoomWebhookEvent event) { - log.info("Zoom Webhook 이벤트 수신 - event: {}", event.getEventName()); + log.info("[ZOOM] [WEBHOOK_RECEIVED] event={}", event.getEventName()); // URL 검증 요청인 경우 별도 처리 (토큰을 포함한 JSON 응답 반환) if (ZoomWebhookEventType.isUrlValidation(event.getEventName())) { diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookService.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookService.java index c65d2bbc..ea5ae020 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookService.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookService.java @@ -1,9 +1,10 @@ package backend.techeerzip.infra.zoom.webhook; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; -import backend.techeerzip.domain.zoom.service.ZoomAttendanceService; import backend.techeerzip.infra.zoom.config.ZoomTokenProvider; +import backend.techeerzip.infra.zoom.dto.ZoomAttendanceEvents; import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; import backend.techeerzip.infra.zoom.dto.ZoomWebhookValidationResponse; import backend.techeerzip.infra.zoom.exception.ZoomWebhookPlainTokenException; @@ -15,7 +16,7 @@ @Service @RequiredArgsConstructor public class ZoomWebhookService { - private final ZoomAttendanceService zoomAttendanceService; + private final ApplicationEventPublisher eventPublisher; private final ZoomTokenProvider zoomTokenProvider; /** URL 검증 요청 처리 */ @@ -23,15 +24,15 @@ public ZoomWebhookValidationResponse handleUrlValidationRequest(ZoomWebhookEvent String plainToken = event.getPayload().getPlainToken(); if (plainToken == null || plainToken.isBlank()) { - log.error("URL 검증 요청에 plainToken이 없습니다"); + log.error("[ZOOM] [URL_VALIDATION] [ERROR] plainToken이 없습니다"); throw new ZoomWebhookPlainTokenException(); } - log.info("Zoom Webhook URL 검증 요청 - plainToken: {}", plainToken); + log.info("[ZOOM] [URL_VALIDATION] [REQUEST] plainToken={}", plainToken); String encryptedToken = zoomTokenProvider.generateEncryptedToken(plainToken); - log.info("Zoom Webhook URL 검증 성공"); + log.info("[ZOOM] [URL_VALIDATION] [SUCCESS]"); return new ZoomWebhookValidationResponse(plainToken, encryptedToken); } @@ -40,39 +41,42 @@ public ZoomWebhookValidationResponse handleWebhookEvents(ZoomWebhookEvent event) event.getPayload().getObject().getParticipant(); String participantUuid = participant.getParticipantUuid(); - String participantUuidLogMsg = - participantUuid != null ? participantUuid.substring(0, 8) + "..." : "없음"; + String uuidShort = participantUuid != null ? participantUuid.substring(0, 8) : "N/A"; /* 참가자 입장 이벤트 처리 */ - if (ZoomWebhookEventType.isParticipantJoined(event.getEventName())) { - // 소회의실 입장 여부 확인을 위한 디버깅 로그 + if (ZoomWebhookEventType.isParticipantJoined(event.getEventName()) + || ZoomWebhookEventType.isParticipantJoinedBreakoutRoom(event.getEventName())) { log.debug( - "입장 이벤트 상세 - participantId: {}, participantUuid: {}, joinTime: {}", + "[ZOOM] [JOIN] [DETAIL] uuid={} participantId={} joinTime={}", + uuidShort, participant.getParticipantId(), - participantUuidLogMsg, participant.getJoinTime()); - zoomAttendanceService.handleParticipantJoined(participant); + eventPublisher.publishEvent( + new ZoomAttendanceEvents.ParticipantJoined( + participant, System.currentTimeMillis())); log.info( - "참가자 입장: {} (uuid: {}, 시간: {})", + "[ZOOM] [JOIN] [EVENT_PUBLISHED] userName={} uuid={} joinTime={}", participant.getUserName(), - participantUuidLogMsg, - participant.getJoinTime() != null ? participant.getJoinTime() : "없음"); + uuidShort, + participant.getJoinTime() != null ? participant.getJoinTime() : "N/A"); } /* 참가자 퇴장 이벤트 처리 */ - if (ZoomWebhookEventType.isParticipantLeft(event.getEventName())) { + if (ZoomWebhookEventType.isParticipantLeft(event.getEventName()) + || ZoomWebhookEventType.isParticipantLeftBreakoutRoom(event.getEventName())) { String leaveReason = participant.getLeaveReason(); log.info( - "참가자 퇴장: {} (uuid: {}, 시간: {}, 사유: {})", + "[ZOOM] [LEFT] [EVENT_PUBLISHED] userName={} uuid={} leaveTime={} reason={}", participant.getUserName(), - participantUuidLogMsg, - participant.getLeaveTime() != null ? participant.getLeaveTime() : "없음", - leaveReason != null ? leaveReason : "없음"); + uuidShort, + participant.getLeaveTime() != null ? participant.getLeaveTime() : "N/A", + leaveReason != null ? leaveReason : "N/A"); - // 도메인 서비스 호출하여 출석 데이터 저장 - zoomAttendanceService.handleParticipantLeft(participant); + eventPublisher.publishEvent( + new ZoomAttendanceEvents.ParticipantLeftAttempt( + participant, System.currentTimeMillis())); } return new ZoomWebhookValidationResponse(null, null); } diff --git a/techeerzip/src/main/resources/logback-spring.xml b/techeerzip/src/main/resources/logback-spring.xml index a58e75b2..150588fa 100644 --- a/techeerzip/src/main/resources/logback-spring.xml +++ b/techeerzip/src/main/resources/logback-spring.xml @@ -63,5 +63,9 @@ + + + +