Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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();
if (redisService.hasZoomPendingLeave(uuid)) {
return;
}
// 없는 경우 보류 등록 (동시성 체크)
boolean isNewPending = redisService.setZoomPendingLeave(uuid, 10L);
if (!isNewPending) {
// 이미 다른 스레드에서 등록했으면 스케줄러 등록하지 않음
return;
}
// 스케쥴러 등록
scheduler.schedule(
() -> {
if (!redisService.hasZoomPendingLeave(uuid)) {
log.debug("[퇴장 취소] 스케줄러 실행 시점에 이미 사용자가 복귀함.");
return;
}
zoomAttendanceService.handleParticipantLeft(participant);
redisService.deleteZoomPendingLeave(uuid);
log.info("[퇴장 확정] {} (UUID: {}) - 세션 종료 처리", participant.getUserName(), uuid);
},
Instant.now().plusSeconds(10));
}

// 입장 이벤트 (줌 접속 / 회의실 이동)
@EventListener
public void handleJoined(ZoomAttendanceEvents.ParticipantJoined joined) {
// 보류 상태면 보류 삭제
WebhookParticipant participant = joined.participant();
String uuid = participant.getParticipantUuid();
if (redisService.hasZoomPendingLeave(uuid)) {
log.info(
"[세션 유지] {} (UUID: {}) - 연속된 접속으로 간주하여 DB 기록 안함",
participant.getUserName(),
uuid);
redisService.deleteZoomPendingLeave(uuid);
return;
}
// 등록 안 되어있으면 새로 생성
log.info("[새 세션 시작] {} (UUID: {})", participant.getUserName(), uuid);
zoomAttendanceService.handleParticipantJoined(participant);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ public void handleParticipantJoined(WebhookParticipant participant) {
LocalDateTime joinTime = parseZoomDateTime(joinTimeStr);
LocalDate meetingDate = joinTime.toLocalDate();

// 활성 세션 조회 (leaveTime이 null인 기록)
// 활성 세션 조회 (소회의실 이동과 줌 나가는 이벤트가 동일해서 leaveTime이 존재할 수 있음)
// participant_uuid는 링크 접속 시 생성되고, 완전히 나갔다 다시 들어오면 새로 할당됨
// 따라서 같은 participant_uuid로 활성 세션이 있으면 = 소회의실에서 메인으로 복귀한 경우
// 따라서 같은 participant_uuid로 활성 세션이 있으면 소회의실, 메인 간 이동한 상황으로 관리
Optional<ZoomAttendance> activeSession =
zoomAttendanceRepository.findActiveSessionByParticipantUuid(participantUuid);

Expand Down Expand Up @@ -92,7 +92,10 @@ public void handleParticipantLeft(WebhookParticipant participant) {
log.warn("leaveTimeStr 정보가 누락되었습니다. uuid: {}", participantUuid);
return;
}

log.debug(
"LeaveReason: participantUuid: {}, leaveReason: {}",
participantUuid,
leaveReason);
// 완전히 나간 케이스만 처리 (소회의실 이동, 대기실 관련 등은 제외)
if (!ZoomLeaveReason.isCompleteExit(leaveReason)) {
log.debug(
Expand All @@ -105,19 +108,19 @@ public void handleParticipantLeft(WebhookParticipant participant) {
// 시간 파싱
LocalDateTime leaveTime = parseZoomDateTime(leaveTimeStr);

// 활성 세션 조회 (leaveTime이 null인 기록만)
// 활성 세션 조회 (소회의실 이동과 줌 나가는 이벤트가 동일해서 leaveTime이 존재할 수 있음)
// participantUuid만으로 조회하여 날짜 경계 문제 해결 (밤 11시 입장 → 다음 날 새벽 퇴장 케이스)
Optional<ZoomAttendance> activeSession =
zoomAttendanceRepository.findActiveSessionByParticipantUuid(participantUuid);

if (activeSession.isEmpty()) {
log.debug(
log.warn(
"활성 세션을 찾을 수 없습니다. participantUuid: {} (이미 완료된 세션이거나 소회의실 퇴장)",
participantUuid);
return; // 활성 세션이 없으면 무시 (이미 완료된 세션이거나 소회의실 퇴장)
return; // 활성 세션이 없으면 무시
}

// 활성 세션의 퇴장 시간 업데이트 (완전히 나간 경우)
// 활성 세션의 퇴장 시간 업데이트
ZoomAttendance attendance = activeSession.get();
attendance.updateLeaveTime(leaveTime);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,19 +10,19 @@
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.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<String, String> redisTemplate;
private final CustomLogger logger;

@PostConstruct
public void init() {
Expand All @@ -31,9 +32,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);
}
}
Expand All @@ -50,9 +51,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);
}
}
Expand All @@ -68,9 +69,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);
}
}
Expand All @@ -79,26 +80,52 @@ public void setTaskStatusWithUserId(String taskId, Long userId) {
public Map<Object, Object> getTaskDetails(String taskId) {
try {
Map<Object, Object> 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-";

public boolean setZoomPendingLeave(String uuid, Long ttlSeconds) {
String key = setZoomPendingKey(uuid);
return Boolean.TRUE.equals(
redisTemplate
.opsForValue()
.setIfAbsent(key, "PENDING", Duration.ofSeconds(ttlSeconds)));
}

public boolean hasZoomPendingLeave(String uuid) {
String key = setZoomPendingKey(uuid);
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}

public boolean deleteZoomPendingLeave(String uuid) {
String key = setZoomPendingKey(uuid);
return deleteTask(key);
}

private static String setZoomPendingKey(String uuid) {
return ZOOM_PENDING_PREFIX + uuid;
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,7 +16,7 @@
@Service
@RequiredArgsConstructor
public class ZoomWebhookService {
private final ZoomAttendanceService zoomAttendanceService;
private final ApplicationEventPublisher eventPublisher;
private final ZoomTokenProvider zoomTokenProvider;

/** URL 검증 요청 처리 */
Expand Down Expand Up @@ -44,15 +45,18 @@ public ZoomWebhookValidationResponse handleWebhookEvents(ZoomWebhookEvent event)
participantUuid != null ? participantUuid.substring(0, 8) + "..." : "없음";

/* 참가자 입장 이벤트 처리 */
if (ZoomWebhookEventType.isParticipantJoined(event.getEventName())) {
if (ZoomWebhookEventType.isParticipantJoined(event.getEventName())
|| ZoomWebhookEventType.isParticipantJoinedBreakoutRoom(event.getEventName())) {
// 소회의실 입장 여부 확인을 위한 디버깅 로그
log.debug(
"입장 이벤트 상세 - participantId: {}, participantUuid: {}, joinTime: {}",
participant.getParticipantId(),
participantUuidLogMsg,
participant.getJoinTime());

zoomAttendanceService.handleParticipantJoined(participant);
eventPublisher.publishEvent(
new ZoomAttendanceEvents.ParticipantJoined(
participant, System.currentTimeMillis()));
log.info(
"참가자 입장: {} (uuid: {}, 시간: {})",
participant.getUserName(),
Expand All @@ -61,7 +65,8 @@ public ZoomWebhookValidationResponse handleWebhookEvents(ZoomWebhookEvent event)
}

/* 참가자 퇴장 이벤트 처리 */
if (ZoomWebhookEventType.isParticipantLeft(event.getEventName())) {
if (ZoomWebhookEventType.isParticipantLeft(event.getEventName())
|| ZoomWebhookEventType.isParticipantLeftBreakoutRoom(event.getEventName())) {
String leaveReason = participant.getLeaveReason();

log.info(
Expand All @@ -72,7 +77,9 @@ public ZoomWebhookValidationResponse handleWebhookEvents(ZoomWebhookEvent event)
leaveReason != null ? leaveReason : "없음");

// 도메인 서비스 호출하여 출석 데이터 저장
zoomAttendanceService.handleParticipantLeft(participant);
eventPublisher.publishEvent(
new ZoomAttendanceEvents.ParticipantLeftAttempt(
participant, System.currentTimeMillis()));
}
return new ZoomWebhookValidationResponse(null, null);
}
Expand Down
Loading