Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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,83 @@
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 uuidShort = uuid != null ? uuid.substring(0, 8) : "N/A";
String userName = participant.getUserName();

// 이미 펜딩 상태면 패스
if (redisService.hasZoomPendingLeave(uuid)) {
log.debug("[ZOOM] [LEFT] [SKIP] 이미 펜딩 상태 - userName={} uuid={}", userName, uuidShort);
return;
}

// 보류 등록 (동시성 체크)
boolean isNewPending = redisService.setZoomPendingLeave(uuid, 10L);
if (!isNewPending) {
log.debug("[ZOOM] [LEFT] [SKIP] 다른 스레드에서 이미 등록됨 - userName={} uuid={}", userName, uuidShort);
return;
}

log.info("[ZOOM] [LEFT] [PENDING] userName={} uuid={} delay=10s", userName, uuidShort);

// 스케줄러 등록
scheduler.schedule(
() -> {
if (!redisService.hasZoomPendingLeave(uuid)) {
log.info("[ZOOM] [LEFT] [CANCELLED] 사용자 복귀 - userName={} uuid={}", userName, uuidShort);
return;
}
zoomAttendanceService.handleParticipantLeft(participant);
redisService.deleteZoomPendingLeave(uuid);
log.info("[ZOOM] [LEFT] [CONFIRMED] 세션 종료 처리 완료 - userName={} uuid={}", userName, uuidShort);
},
Instant.now().plusSeconds(10));
}

// 입장 이벤트 (줌 접속 / 회의실 이동)
@EventListener
public void handleJoined(ZoomAttendanceEvents.ParticipantJoined joined) {
WebhookParticipant participant = joined.participant();
String uuid = participant.getParticipantUuid();
String uuidShort = uuid != null ? uuid.substring(0, 8) : "N/A";
String userName = participant.getUserName();

// 보류 상태면 보류 삭제 (연속 접속으로 간주)
if (redisService.hasZoomPendingLeave(uuid)) {
log.info(
"[ZOOM] [JOIN] [SESSION_KEPT] 연속 접속으로 간주, DB 기록 안함 - userName={} uuid={}",
userName,
uuidShort);
redisService.deleteZoomPendingLeave(uuid);
return;
}

// 새 세션 시작
log.info("[ZOOM] [JOIN] [NEW_SESSION] 새 세션 시작 - userName={} uuid={}", userName, uuidShort);
zoomAttendanceService.handleParticipantJoined(participant);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,36 +27,34 @@ 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;
}

// 시간 파싱 (Zoom은 UTC ISO 8601 형식)
LocalDateTime joinTime = parseZoomDateTime(joinTimeStr);
LocalDate meetingDate = joinTime.toLocalDate();

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

/** 참가자 퇴장 이벤트 처리 */
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;
}

// 시간 파싱
LocalDateTime leaveTime = parseZoomDateTime(leaveTimeStr);

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

Expand All @@ -144,7 +158,7 @@ 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
}
}
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);
}
}
Loading
Loading