diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsScheduler.java b/techeerzip/src/main/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsScheduler.java index beb6ff57..df360e2c 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsScheduler.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsScheduler.java @@ -34,8 +34,7 @@ public void todayCsPublishScheduler() { String.format( "[ERROR] CS 문제 발행 중 오류 발생!\n> 원인: %s\n> 시간: %s", e.getMessage(), java.time.LocalDateTime.now()); - eventPublisher.publishEvent( - new SlackEvent.Channel(errorMessage, SlackChannelType.EA)); + eventPublisher.publishEvent(new SlackEvent.Channel(errorMessage, SlackChannelType.EA)); } } } diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/zoom/repository/ZoomAttendanceRepository.java b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/repository/ZoomAttendanceRepository.java index 19015da2..a55f6cde 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/zoom/repository/ZoomAttendanceRepository.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/repository/ZoomAttendanceRepository.java @@ -37,6 +37,12 @@ Optional findActiveSessionByParticipantUuidAndMeetingDate( @Param("participantUuid") String participantUuid, @Param("meetingDate") LocalDate meetingDate); + /** participantUuid로 활성 세션 조회 (날짜 경계 문제 해결을 위해 날짜 조건 제외) */ + @Query( + "SELECT z FROM ZoomAttendance z WHERE z.participantUuid = :participantUuid AND z.leaveTime IS NULL") + Optional findActiveSessionByParticipantUuid( + @Param("participantUuid") String participantUuid); + /** 특정 사용자의 월별 통계 조회 */ @Query( "SELECT new backend.techeerzip.domain.zoom.dto.response.ZoomMonthlyStatsDto(" 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 78d0520b..11b83798 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 @@ -10,8 +10,8 @@ import backend.techeerzip.domain.zoom.entity.ZoomAttendance; import backend.techeerzip.domain.zoom.repository.ZoomAttendanceRepository; -import backend.techeerzip.infra.zoom.client.ZoomApiClient; -import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent.WebhookParticipant; +import backend.techeerzip.infra.zoom.type.ZoomLeaveReason; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,49 +21,17 @@ @Transactional public class ZoomAttendanceService { - private final ZoomApiClient zoomApiClient; private final ZoomAttendanceRepository zoomAttendanceRepository; - /** API 연결 테스트 */ - public boolean testConnection() { - return zoomApiClient.testConnection(); - } - - /** 웹훅 이벤트 처리 */ - public void handleWebhookEvent(ZoomWebhookEvent event) { - try { - String eventType = event.getEvent(); - - if ("meeting.participant_joined".equals(eventType)) { - handleParticipantJoined(event); - } else if ("meeting.participant_left".equals(eventType)) { - handleParticipantLeft(event); - } - - log.debug("ZoomService 처리 완료: {}", eventType); - - } catch (Exception e) { - log.error("ZoomService 처리 오류: {}", e.getMessage(), e); - } - } - /** 참가자 입장 이벤트 처리 */ - private void handleParticipantJoined(ZoomWebhookEvent event) { + public void handleParticipantJoined(WebhookParticipant participant) { try { - ZoomWebhookEvent.WebhookParticipant participant = - event.getPayload().getObject().getParticipant(); - - if (participant == null) { - log.warn("참가자 정보가 없습니다."); - return; - } - String participantUuid = participant.getParticipantUuid(); String userName = participant.getUserName(); String joinTimeStr = participant.getJoinTime(); - if (participantUuid == null || joinTimeStr == null) { - log.warn("필수 정보가 누락되었습니다. uuid: {}, joinTime: {}", participantUuid, joinTimeStr); + if (joinTimeStr == null) { + log.warn("joinTimeStr 정보가 누락되었습니다. uuid: {}", participantUuid); return; } @@ -71,19 +39,24 @@ private void handleParticipantJoined(ZoomWebhookEvent event) { LocalDateTime joinTime = parseZoomDateTime(joinTimeStr); LocalDate meetingDate = joinTime.toLocalDate(); - // 기존 출석 기록 조회 - Optional existingRecord = - zoomAttendanceRepository.findByParticipantUuidAndMeetingDate( - participantUuid, meetingDate); + // 활성 세션 조회 (leaveTime이 null인 기록) + // participant_uuid는 링크 접속 시 생성되고, 완전히 나갔다 다시 들어오면 새로 할당됨 + // 따라서 같은 participant_uuid로 활성 세션이 있으면 = 소회의실에서 메인으로 복귀한 경우 + Optional activeSession = + zoomAttendanceRepository.findActiveSessionByParticipantUuid(participantUuid); - if (existingRecord.isPresent()) { - log.info( - "기존 출석 기록이 존재합니다. participantUuid: {}, date: {}", + if (activeSession.isPresent()) { + log.debug( + "활성 세션이 존재합니다. participantUuid: {}, date: {} (소회의실에서 메인으로 복귀 무시)", participantUuid, meetingDate); - return; // 이미 있는 경우 무시 (소회의실에서 복귀한 경우) + return; // 활성 세션이 있으면 무시 (소회의실에서 메인으로 복귀한 경우) } + // 완전히 나갔다 다시 들어온 경우 (새로운 participant_uuid) 또는 첫 입장 + // 기존에 완료된 세션이 있는지 확인 (같은 날짜에 다른 participant_uuid로 기록이 있을 수 있음) + // 하지만 participant_uuid가 다르면 새로운 세션이므로 별도 엔티티로 저장 + // 새로운 출석 기록 생성 (입장 시에는 leaveTime과 durationMinutes는 null) ZoomAttendance attendance = ZoomAttendance.builder() @@ -109,53 +82,43 @@ private void handleParticipantJoined(ZoomWebhookEvent event) { } /** 참가자 퇴장 이벤트 처리 */ - private void handleParticipantLeft(ZoomWebhookEvent event) { + public void handleParticipantLeft(WebhookParticipant participant) { try { - ZoomWebhookEvent.WebhookParticipant participant = - event.getPayload().getObject().getParticipant(); - - if (participant == null) { - log.warn("참가자 정보가 없습니다."); - return; - } - String participantUuid = participant.getParticipantUuid(); String leaveReason = participant.getLeaveReason(); String leaveTimeStr = participant.getLeaveTime(); - if (participantUuid == null || leaveTimeStr == null) { - log.warn("필수 정보가 누락되었습니다. uuid: {}, leaveTime: {}", participantUuid, leaveTimeStr); + if (leaveTimeStr == null) { + log.warn("leaveTimeStr 정보가 누락되었습니다. uuid: {}", participantUuid); return; } - // 소회의실 관련 이벤트 무시 - if (leaveReason != null) { - if (leaveReason.contains("join breakout room") - || leaveReason.contains("leave breakout room to join main meeting")) { - log.debug("소회의실 관련 이벤트 무시: {} - {}", participantUuid, leaveReason); - return; - } + // 완전히 나간 케이스만 처리 (소회의실 이동, 대기실 관련 등은 제외) + if (!ZoomLeaveReason.isCompleteExit(leaveReason)) { + log.debug( + "완전히 나간 케이스가 아닙니다. participantUuid: {}, leaveReason: {} (무시)", + participantUuid, + leaveReason); + return; } // 시간 파싱 LocalDateTime leaveTime = parseZoomDateTime(leaveTimeStr); - LocalDate meetingDate = leaveTime.toLocalDate(); - // 기존 출석 기록 조회 - Optional existingRecord = - zoomAttendanceRepository.findByParticipantUuidAndMeetingDate( - participantUuid, meetingDate); + // 활성 세션 조회 (leaveTime이 null인 기록만) + // participantUuid만으로 조회하여 날짜 경계 문제 해결 (밤 11시 입장 → 다음 날 새벽 퇴장 케이스) + Optional activeSession = + zoomAttendanceRepository.findActiveSessionByParticipantUuid(participantUuid); - if (existingRecord.isEmpty()) { - log.warn( - "출석 기록을 찾을 수 없습니다. participantUuid: {}, date: {}", - participantUuid, - meetingDate); - return; + if (activeSession.isEmpty()) { + log.debug( + "활성 세션을 찾을 수 없습니다. participantUuid: {} (이미 완료된 세션이거나 소회의실 퇴장)", + participantUuid); + return; // 활성 세션이 없으면 무시 (이미 완료된 세션이거나 소회의실 퇴장) } - // 퇴장 시간 업데이트 - ZoomAttendance attendance = existingRecord.get(); + // 활성 세션의 퇴장 시간 업데이트 (완전히 나간 경우) + ZoomAttendance attendance = activeSession.get(); attendance.updateLeaveTime(leaveTime); zoomAttendanceRepository.save(attendance); diff --git a/techeerzip/src/main/java/backend/techeerzip/global/config/SecurityConfig.java b/techeerzip/src/main/java/backend/techeerzip/global/config/SecurityConfig.java index 972fe0d7..871be1a5 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/config/SecurityConfig.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/config/SecurityConfig.java @@ -7,6 +7,7 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -36,10 +37,9 @@ public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws http.securityMatcher("/api/v3/docs/**", "/api/v3/swagger-ui/**", "/api/v3/api-docs/**") .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.realmName("Swagger API Documentation")) - .csrf(csrf -> csrf.disable()) + .csrf(AbstractHttpConfigurer::disable) .sessionManagement( session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - return http.build(); } @@ -111,7 +111,7 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce .permitAll() .anyRequest() .authenticated()) - .csrf(csrf -> csrf.disable()) + .csrf(AbstractHttpConfigurer::disable) .exceptionHandling( ex -> ex.authenticationEntryPoint(customAuthenticationEntryPoint) diff --git a/techeerzip/src/main/java/backend/techeerzip/global/config/WebConfig.java b/techeerzip/src/main/java/backend/techeerzip/global/config/WebConfig.java index 70f73415..afa8da1f 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/config/WebConfig.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/config/WebConfig.java @@ -15,7 +15,7 @@ @Configuration public class WebConfig implements WebMvcConfigurer { - private OctetStreamReadMsgConverter octetStreamReadMsgConverter; + private final OctetStreamReadMsgConverter octetStreamReadMsgConverter; @Override public void addCorsMappings(CorsRegistry registry) { 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 6949bc4a..2357b235 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java @@ -225,7 +225,21 @@ public enum ErrorCode { GITHUB_INVALID_URL(HttpStatus.BAD_REQUEST, "GH001", "유효하지 않은 GitHub URL입니다."), GITHUB_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "GH002", "GitHub 사용자를 찾을 수 없습니다. 사용자명을 확인해주세요."), GITHUB_API_ERROR(HttpStatus.BAD_GATEWAY, "GH003", "GitHub API 호출 중 오류가 발생했습니다."), - GITHUB_SERVER_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "GH004", "GitHub 서버와 통신할 수 없습니다."); + GITHUB_SERVER_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "GH004", "GitHub 서버와 통신할 수 없습니다."), + + // Zoom + ZOOM_WEBHOOK_SECRET_TOKEN_NOT_CONFIGURED( + HttpStatus.INTERNAL_SERVER_ERROR, "ZOOM001", "웹훅 secret token이 설정되지 않았습니다."), + ZOOM_TOKEN_ENCRYPTION_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "ZOOM002", "토큰 암호화 중 오류가 발생했습니다."), + ZOOM_OAUTH_TOKEN_GENERATION_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "ZOOM003", "Zoom OAuth 토큰 생성에 실패했습니다."), + ZOOM_WEBHOOK_AUTHENTICATION_FAILED( + HttpStatus.UNAUTHORIZED, "ZOOM004", "Zoom Webhook 인증에 실패했습니다."), + ZOOM_WEBHOOK_PROCESSING_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "ZOOM005", "Zoom Webhook 처리 중 오류가 발생했습니다."), + ZOOM_WEBHOOK_PLAIN_TOKEN_MISSING( + HttpStatus.BAD_REQUEST, "ZOOM006", "URL 검증 요청에 plainToken이 없습니다."); private final HttpStatus status; private final String code; diff --git a/techeerzip/src/main/java/backend/techeerzip/global/exception/GlobalExceptionHandler.java b/techeerzip/src/main/java/backend/techeerzip/global/exception/GlobalExceptionHandler.java index a094395f..18b6c1c9 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/exception/GlobalExceptionHandler.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/exception/GlobalExceptionHandler.java @@ -27,7 +27,7 @@ public ResponseEntity handleValidationExceptions( e.getBindingResult() .getAllErrors() .forEach( - (error) -> { + error -> { if (error instanceof FieldError fieldError) { String fieldName = fieldError.getField(); String errorMessage = error.getDefaultMessage(); diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/client/ZoomApiClient.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/client/ZoomApiClient.java deleted file mode 100644 index 9d9c7ac8..00000000 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/client/ZoomApiClient.java +++ /dev/null @@ -1,133 +0,0 @@ -package backend.techeerzip.infra.zoom.client; - -import java.time.Instant; -import java.util.Base64; - -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -import backend.techeerzip.domain.zoom.exception.ZoomApiException; -import backend.techeerzip.infra.zoom.config.ZoomApiConfig; -import backend.techeerzip.infra.zoom.dto.ZoomOAuthTokenResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ZoomApiClient { - - private final ZoomApiConfig zoomApiConfig; - private final RestTemplate zoomRestTemplate; - - private String cachedAccessToken; - private Instant tokenExpiryTime; - - /** OAuth 액세스 토큰 생성 및 캐싱 */ - public String getAccessToken() { - // 토큰이 만료되지 않았다면 캐시된 토큰 반환 (5분 여유 두고 갱신) - if (cachedAccessToken != null - && tokenExpiryTime != null - && Instant.now().plusSeconds(300).isBefore(tokenExpiryTime)) { - log.debug("Using cached access token"); - return cachedAccessToken; - } - - // 새 토큰 생성 - log.info("Generating new Zoom OAuth access token"); - ZoomOAuthTokenResponse tokenResponse = generateOAuthToken(); - - if (tokenResponse != null && tokenResponse.getAccessToken() != null) { - cachedAccessToken = tokenResponse.getAccessToken(); - // 토큰 만료 시간 설정 (현재 시간 + expires_in 초) - tokenExpiryTime = Instant.now().plusSeconds(tokenResponse.getExpiresIn()); - log.info( - "Successfully generated OAuth token, expires in {} seconds", - tokenResponse.getExpiresIn()); - } else { - log.error("Failed to generate OAuth token"); - throw new ZoomApiException("Failed to generate Zoom OAuth token"); - } - - return cachedAccessToken; - } - - /** OAuth 토큰 생성 (Server-to-Server OAuth) */ - private ZoomOAuthTokenResponse generateOAuthToken() { - try { - String tokenUrl = zoomApiConfig.getTokenUrl(); - - // Basic Auth 헤더 생성 - String credentials = - zoomApiConfig.getClientId() + ":" + zoomApiConfig.getClientSecret(); - String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes()); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - headers.set("Authorization", "Basic " + encodedCredentials); - - // 요청 바디 생성 - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", "account_credentials"); - body.add("account_id", zoomApiConfig.getAccountId()); - - HttpEntity> entity = new HttpEntity<>(body, headers); - - ResponseEntity response = - zoomRestTemplate.exchange( - tokenUrl, HttpMethod.POST, entity, ZoomOAuthTokenResponse.class); - - if (response.getStatusCode() == HttpStatus.OK) { - return response.getBody(); - } else { - log.error("Failed to get OAuth token, status: {}", response.getStatusCode()); - return null; - } - - } catch (Exception e) { - log.error("Error generating OAuth token: {}", e.getMessage(), e); - throw new ZoomApiException("Failed to generate Zoom OAuth token", e); - } - } - - /** 인증 헤더 생성 */ - private HttpHeaders createAuthHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + getAccessToken()); - headers.setContentType(MediaType.APPLICATION_JSON); - return headers; - } - - /** API 연결 테스트 - 사용자 정보 조회로 연결 확인 */ - public boolean testConnection() { - try { - String url = zoomApiConfig.getBaseUrl() + "/users/me"; - - HttpHeaders headers = createAuthHeaders(); - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity response = - zoomRestTemplate.exchange(url, HttpMethod.GET, entity, String.class); - - if (response.getStatusCode() == HttpStatus.OK) { - log.info("✅ Zoom API 연결 테스트 성공"); - return true; - } else { - log.warn("❌ Zoom API 연결 테스트 실패: {}", response.getStatusCode()); - return false; - } - - } catch (Exception e) { - log.error("❌ Zoom API 연결 테스트 중 오류: {}", e.getMessage(), e); - return false; - } - } -} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomApiConfig.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomApiConfig.java index 792286dd..4cf30560 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomApiConfig.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomApiConfig.java @@ -1,9 +1,7 @@ package backend.techeerzip.infra.zoom.config; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; import lombok.Getter; @@ -11,21 +9,6 @@ @Getter public class ZoomApiConfig { - @Value("${zoom.api.base-url:https://api.zoom.us/v2}") - private String baseUrl; - - @Value("${zoom.api.token-url:https://zoom.us/oauth/token}") - private String tokenUrl; - - @Value("${zoom.api.account-id}") - private String accountId; - - @Value("${zoom.api.client-id}") - private String clientId; - - @Value("${zoom.api.client-secret}") - private String clientSecret; - @Value("${zoom.webhook.secret-token:}") private String webhookSecretToken; @@ -34,9 +17,4 @@ public class ZoomApiConfig { @Value("${zoom.meetings.default}") private String defaultMeetingId; - - @Bean - public RestTemplate zoomRestTemplate() { - return new RestTemplate(); - } } diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomAuth.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomAuth.java new file mode 100644 index 00000000..9f193372 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomAuth.java @@ -0,0 +1,10 @@ +package backend.techeerzip.infra.zoom.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ZoomAuth {} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomTokenProvider.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomTokenProvider.java new file mode 100644 index 00000000..39126031 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomTokenProvider.java @@ -0,0 +1,56 @@ +package backend.techeerzip.infra.zoom.config; + +import java.nio.charset.StandardCharsets; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; + +import backend.techeerzip.global.exception.ErrorCode; +import backend.techeerzip.infra.zoom.exception.ZoomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ZoomTokenProvider { + + private final ZoomApiConfig zoomApiConfig; + + /** Zoom 웹훅 URL 검증을 위한 암호화된 토큰 생성 plainToken과 webhookSecretToken을 사용하여 HMAC-SHA256으로 암호화 */ + public String generateEncryptedToken(String plainToken) { + String secretToken = zoomApiConfig.getWebhookSecretToken(); + + if (secretToken == null || secretToken.isEmpty()) { + log.error("Webhook secret token이 설정되지 않았습니다"); + throw new ZoomException(ErrorCode.ZOOM_WEBHOOK_SECRET_TOKEN_NOT_CONFIGURED); + } + + try { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = + new SecretKeySpec(secretToken.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + + byte[] encryptedBytes = mac.doFinal(plainToken.getBytes(StandardCharsets.UTF_8)); + + // 16진수 문자열로 변환 + StringBuilder hexString = new StringBuilder(); + for (byte b : encryptedBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + + } catch (Exception e) { + log.error("토큰 암호화 중 오류: {}", e.getMessage(), e); + throw new ZoomException(ErrorCode.ZOOM_TOKEN_ENCRYPTION_FAILED, e); + } + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomWebhookAspect.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomWebhookAspect.java new file mode 100644 index 00000000..937ddb6a --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomWebhookAspect.java @@ -0,0 +1,82 @@ +package backend.techeerzip.infra.zoom.config; + +import java.util.Arrays; + +import jakarta.servlet.http.HttpServletRequest; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; +import backend.techeerzip.infra.zoom.exception.ZoomWebhookInvalidAuthenticationException; +import backend.techeerzip.infra.zoom.type.ZoomWebhookEventType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class ZoomWebhookAspect { + + private final ZoomApiConfig zoomApiConfig; + + @Before("@annotation(backend.techeerzip.infra.zoom.config.ZoomAuth)") + public void validationZoomToken(JoinPoint joinPoint) { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); + HttpServletRequest request = attributes.getRequest(); + + ZoomWebhookEvent event = + Arrays.stream(joinPoint.getArgs()) + .filter(ZoomWebhookEvent.class::isInstance) + .map(ZoomWebhookEvent.class::cast) + .findFirst() + .orElseThrow(ZoomWebhookInvalidAuthenticationException::new); + + if (ZoomWebhookEventType.isUrlValidation(event.getEventName())) { + log.debug("URL 검증 요청 - 인증 검증 생략"); + return; + } + + // 일반 웹훅 이벤트는 인증 검증 + String authHeader = + request.getHeader("authorization") != null + ? request.getHeader("authorization") + : request.getHeader("Authorization"); + + if (!isValidWebhook(authHeader)) { + log.warn("Invalid webhook authorization header"); + throw new ZoomWebhookInvalidAuthenticationException(); + } + } + + /** Webhook 인증 검증 */ + private boolean isValidWebhook(String authHeader) { + // Webhook verification token이 설정되지 않은 경우 + if (zoomApiConfig.getWebhookVerificationToken() == null + || zoomApiConfig.getWebhookVerificationToken().isEmpty()) { + log.warn("Webhook verification token not configured"); + return false; + } + + if (authHeader == null || authHeader.trim().isEmpty()) { + log.warn("Missing authorization header"); + return false; + } + + // Zoom은 Bearer 접두사 없이 토큰만 보냄 + String token = authHeader.trim(); + boolean isValid = zoomApiConfig.getWebhookVerificationToken().equals(token); + + if (!isValid) { + log.warn("Invalid webhook token"); + } + + return isValid; + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomOAuthTokenResponse.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomOAuthTokenResponse.java deleted file mode 100644 index 8da1a1d8..00000000 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomOAuthTokenResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package backend.techeerzip.infra.zoom.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; - -@Getter -@NoArgsConstructor -@ToString -public class ZoomOAuthTokenResponse { - - @JsonProperty("access_token") - private String accessToken; - - @JsonProperty("token_type") - private String tokenType; - - @JsonProperty("expires_in") - private Long expiresIn; - - @JsonProperty("scope") - private String scope; -} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookEvent.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookEvent.java index 95a89b15..55cd6bba 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookEvent.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookEvent.java @@ -1,27 +1,33 @@ package backend.techeerzip.infra.zoom.dto; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @Getter @NoArgsConstructor @Slf4j +@JsonIgnoreProperties(ignoreUnknown = true) public class ZoomWebhookEvent { + @NonNull @JsonProperty("event") - private String event; + private String eventName; @JsonProperty("event_ts") private Long eventTimestamp; + @NonNull @JsonProperty("payload") private WebhookPayload payload; @Getter @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) public static class WebhookPayload { @JsonProperty("account_id") @@ -29,10 +35,14 @@ public static class WebhookPayload { @JsonProperty("object") private WebhookObject object; + + @JsonProperty("plainToken") + private String plainToken; // URL 검증 요청 시 사용 } @Getter @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) public static class WebhookObject { @JsonProperty("uuid") @@ -65,6 +75,7 @@ public static class WebhookObject { @Getter @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) public static class WebhookParticipant { @JsonProperty("user_id") @@ -90,5 +101,11 @@ public static class WebhookParticipant { @JsonProperty("leave_reason") private String leaveReason; + + @JsonProperty("public_ip") + private String publicIp; + + @JsonProperty("participant_user_id") + private String participantUserId; } } diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookValidationResponse.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookValidationResponse.java new file mode 100644 index 00000000..f0cef2c5 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookValidationResponse.java @@ -0,0 +1,20 @@ +package backend.techeerzip.infra.zoom.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** Zoom 웹훅 URL 검증 응답 DTO */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ZoomWebhookValidationResponse { + + @JsonProperty("plainToken") + private String plainToken; + + @JsonProperty("encryptedToken") + private String encryptedToken; +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomException.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomException.java new file mode 100644 index 00000000..e15e633e --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomException.java @@ -0,0 +1,16 @@ +package backend.techeerzip.infra.zoom.exception; + +import backend.techeerzip.global.exception.ErrorCode; +import backend.techeerzip.global.exception.InfraException; + +/** Zoom 관련 인프라 예외 */ +public class ZoomException extends InfraException { + + public ZoomException(ErrorCode errorCode) { + super(errorCode); + } + + public ZoomException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookInvalidAuthenticationException.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookInvalidAuthenticationException.java new file mode 100644 index 00000000..e39caf6b --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookInvalidAuthenticationException.java @@ -0,0 +1,21 @@ +package backend.techeerzip.infra.zoom.exception; + +import org.springframework.security.core.AuthenticationException; + +import backend.techeerzip.global.exception.ErrorCode; + +/** + * Zoom Webhook 인증 실패 예외 AuthenticationException을 상속하여 Spring Security의 exception handling에서 처리되도록 함 + */ +public class ZoomWebhookInvalidAuthenticationException extends AuthenticationException { + + private static final ErrorCode ERROR_CODE = ErrorCode.ZOOM_WEBHOOK_AUTHENTICATION_FAILED; + + public ZoomWebhookInvalidAuthenticationException() { + super(ERROR_CODE.getMessage()); + } + + public ErrorCode getErrorCode() { + return ERROR_CODE; + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookPlainTokenException.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookPlainTokenException.java new file mode 100644 index 00000000..b0e48769 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookPlainTokenException.java @@ -0,0 +1,12 @@ +package backend.techeerzip.infra.zoom.exception; + +import backend.techeerzip.global.exception.ErrorCode; +import backend.techeerzip.global.exception.InfraException; + +/** Zoom Webhook 처리 중 발생하는 예외 */ +public class ZoomWebhookPlainTokenException extends InfraException { + + public ZoomWebhookPlainTokenException() { + super(ErrorCode.ZOOM_WEBHOOK_PLAIN_TOKEN_MISSING); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookProcessingException.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookProcessingException.java new file mode 100644 index 00000000..122dbad7 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookProcessingException.java @@ -0,0 +1,12 @@ +package backend.techeerzip.infra.zoom.exception; + +import backend.techeerzip.global.exception.ErrorCode; +import backend.techeerzip.global.exception.InfraException; + +/** Zoom Webhook 처리 중 발생하는 예외 */ +public class ZoomWebhookProcessingException extends InfraException { + + public ZoomWebhookProcessingException(Throwable cause) { + super(ErrorCode.ZOOM_WEBHOOK_PROCESSING_FAILED, cause); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomLeaveReason.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomLeaveReason.java new file mode 100644 index 00000000..d542b9bf --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomLeaveReason.java @@ -0,0 +1,79 @@ +package backend.techeerzip.infra.zoom.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** Zoom 참가자 퇴장 사유 완전히 나간 케이스만 정의 (소회의실 이동 등은 제외) */ +@Getter +@RequiredArgsConstructor +public enum ZoomLeaveReason { + /** 참가자가 미팅을 나감 */ + LEFT_MEETING("left the meeting"), + + /** 참가자가 연결이 끊김 */ + DISCONNECTED("got disconnected from the meeting"), + + /** 호스트가 미팅을 종료 */ + HOST_ENDED("Host ended the meeting"), + + /** 호스트가 미팅을 닫음 */ + HOST_CLOSED("Host closed the meeting"), + + /** 호스트가 새 미팅을 시작 */ + HOST_STARTED_NEW("Host started a new meeting"), + + /** 네트워크 연결 오류 */ + NETWORK_ERROR("Network connection error"), + + /** 무료 미팅 시간 한도 초과 */ + EXCEEDED_FREE_LIMIT("Exceeded free meeting minutes limit"), + + /** 호스트가 참가자를 제거 */ + REMOVED_BY_HOST("Removed by host"), + + /** 알 수 없는 이유 */ + UNKNOWN("Unknown reason"); + + private final String reasonText; + + /** + * 주어진 leaveReason 문자열이 완전히 나간 케이스인지 확인 + * + * @param leaveReason Zoom에서 받은 leaveReason 문자열 (null 가능) + * @return 완전히 나간 케이스면 true, 그 외(소회의실 이동, 대기실 관련 등)는 false + */ + public static boolean isCompleteExit(String leaveReason) { + if (leaveReason == null || leaveReason.isBlank()) { + return false; + } + + String lowerReason = leaveReason.toLowerCase(); + + // 소회의실 관련 이벤트는 제외 + if (lowerReason.contains("breakout room") + || lowerReason.contains("join breakout") + || lowerReason.contains("leave breakout")) { + return false; + } + + // 대기실 관련 이벤트는 제외 + if (lowerReason.contains("waiting room")) { + return false; + } + + // 호스트가 참여하지 않은 경우는 제외 (퇴장이 아님) + if (lowerReason.contains("host did not join")) { + return false; + } + + // 완전히 나간 케이스 체크 + for (ZoomLeaveReason reason : values()) { + if (lowerReason.contains(reason.reasonText.toLowerCase())) { + return true; + } + } + + // 명시적으로 정의된 케이스가 아니면 false 반환 (안전하게 처리) + return false; + } +} 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 new file mode 100644 index 00000000..d609a5ee --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomWebhookEventType.java @@ -0,0 +1,28 @@ +package backend.techeerzip.infra.zoom.type; + +import lombok.Getter; + +@Getter +public enum ZoomWebhookEventType { + URL_VALIDATION("endpoint.url_validation"), + PARTICIPANT_JOINED("meeting.participant_joined"), + PARTICIPANT_LEFT("meeting.participant_left"); + + private final String eventName; + + ZoomWebhookEventType(String eventName) { + this.eventName = eventName; + } + + public static boolean isUrlValidation(String eventName) { + return URL_VALIDATION.getEventName().equals(eventName); + } + + public static boolean isParticipantJoined(String eventName) { + return PARTICIPANT_JOINED.getEventName().equals(eventName); + } + + public static boolean isParticipantLeft(String eventName) { + return PARTICIPANT_LEFT.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 new file mode 100644 index 00000000..828be562 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookController.java @@ -0,0 +1,36 @@ +package backend.techeerzip.infra.zoom.webhook; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import backend.techeerzip.infra.zoom.config.ZoomAuth; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookValidationResponse; +import backend.techeerzip.infra.zoom.type.ZoomWebhookEventType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class ZoomWebhookController { + + private final ZoomWebhookService webhookService; + + /** Zoom Webhook 이벤트 수신 */ + @PostMapping("/api/v3/zoom/webhook/events") + @ZoomAuth + public ResponseEntity handleWebhookEvent( + @RequestBody ZoomWebhookEvent event) { + + log.info("Zoom Webhook 이벤트 수신 - event: {}", event.getEventName()); + + // URL 검증 요청인 경우 별도 처리 (토큰을 포함한 JSON 응답 반환) + if (ZoomWebhookEventType.isUrlValidation(event.getEventName())) { + return ResponseEntity.ok(webhookService.handleUrlValidationRequest(event)); + } + return ResponseEntity.ok(webhookService.handleWebhookEvents(event)); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookHandler.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookHandler.java deleted file mode 100644 index 261ac3b2..00000000 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookHandler.java +++ /dev/null @@ -1,135 +0,0 @@ -package backend.techeerzip.infra.zoom.webhook; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RestController; - -import backend.techeerzip.domain.zoom.service.ZoomAttendanceService; -import backend.techeerzip.infra.zoom.config.ZoomApiConfig; -import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RestController -@RequiredArgsConstructor -public class ZoomWebhookHandler { - - private final ZoomApiConfig zoomApiConfig; - private final ZoomAttendanceService zoomAttendanceService; - - /** Zoom Webhook 이벤트 수신 */ - @PostMapping("/api/v3/zoom/webhook/events") - public ResponseEntity handleWebhookEvent( - @RequestHeader(value = "authorization", required = false) String authHeader, - @RequestBody ZoomWebhookEvent event) { - - try { - // Webhook 검증 - if (!isValidWebhook(authHeader)) { - log.warn("Invalid webhook authorization header"); - return ResponseEntity.status(401).body("Unauthorized"); - } - - // 이벤트 타입별 처리 - String eventType = event.getEvent(); - if (eventType == null) { - log.warn("Event type is null"); - return ResponseEntity.badRequest().body("Invalid event"); - } - - switch (eventType) { - case "meeting.participant_joined": - handleParticipantJoined(event); - break; - case "meeting.participant_left": - handleParticipantLeft(event); - break; - default: - log.debug("Unhandled event type: {}", eventType); - break; - } - - return ResponseEntity.ok("OK"); - - } catch (Exception e) { - log.error("웹훅 처리 오류: {}", e.getMessage(), e); - return ResponseEntity.status(500).body("Internal Server Error"); - } - } - - /** Webhook 인증 검증 */ - private boolean isValidWebhook(String authHeader) { - // Webhook verification token이 설정되지 않은 경우 - if (zoomApiConfig.getWebhookVerificationToken() == null - || zoomApiConfig.getWebhookVerificationToken().isEmpty()) { - log.warn("Webhook verification token not configured"); - return false; - } - - if (authHeader == null || authHeader.trim().isEmpty()) { - log.warn("Missing authorization header"); - return false; - } - - // Zoom은 Bearer 접두사 없이 토큰만 보냄 - String token = authHeader.trim(); - boolean isValid = zoomApiConfig.getWebhookVerificationToken().equals(token); - - if (!isValid) { - log.warn("Invalid webhook token"); - } - - return isValid; - } - - /** 참가자 입장 이벤트 처리 */ - private void handleParticipantJoined(ZoomWebhookEvent event) { - try { - ZoomWebhookEvent.WebhookParticipant participant = - event.getPayload().getObject().getParticipant(); - - if (participant != null) { - String participantUuid = participant.getParticipantUuid(); - - log.info( - "참가자 입장: {} (uuid: {}, 시간: {})", - participant.getUserName(), - participantUuid != null ? participantUuid.substring(0, 8) + "..." : "없음", - participant.getJoinTime() != null ? participant.getJoinTime() : "없음"); - - // 도메인 서비스 호출하여 출석 데이터 저장 - zoomAttendanceService.handleWebhookEvent(event); - } - } catch (Exception e) { - log.error("참가자 입장 이벤트 처리 오류: {}", e.getMessage(), e); - } - } - - /** 참가자 퇴장 이벤트 처리 */ - private void handleParticipantLeft(ZoomWebhookEvent event) { - try { - ZoomWebhookEvent.WebhookParticipant participant = - event.getPayload().getObject().getParticipant(); - - if (participant != null) { - String participantUuid = participant.getParticipantUuid(); - String leaveReason = participant.getLeaveReason(); - - log.info( - "참가자 퇴장: {} (uuid: {}, 시간: {}, 사유: {})", - participant.getUserName(), - participantUuid != null ? participantUuid.substring(0, 8) + "..." : "없음", - participant.getLeaveTime() != null ? participant.getLeaveTime() : "없음", - leaveReason != null ? leaveReason : "없음"); - - // 도메인 서비스 호출하여 출석 데이터 저장 - zoomAttendanceService.handleWebhookEvent(event); - } - } catch (Exception e) { - log.error("참가자 퇴장 이벤트 처리 오류: {}", e.getMessage(), e); - } - } -} 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 new file mode 100644 index 00000000..c65d2bbc --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookService.java @@ -0,0 +1,79 @@ +package backend.techeerzip.infra.zoom.webhook; + +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.ZoomWebhookEvent; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookValidationResponse; +import backend.techeerzip.infra.zoom.exception.ZoomWebhookPlainTokenException; +import backend.techeerzip.infra.zoom.type.ZoomWebhookEventType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ZoomWebhookService { + private final ZoomAttendanceService zoomAttendanceService; + private final ZoomTokenProvider zoomTokenProvider; + + /** URL 검증 요청 처리 */ + public ZoomWebhookValidationResponse handleUrlValidationRequest(ZoomWebhookEvent event) { + String plainToken = event.getPayload().getPlainToken(); + + if (plainToken == null || plainToken.isBlank()) { + log.error("URL 검증 요청에 plainToken이 없습니다"); + throw new ZoomWebhookPlainTokenException(); + } + + log.info("Zoom Webhook URL 검증 요청 - plainToken: {}", plainToken); + + String encryptedToken = zoomTokenProvider.generateEncryptedToken(plainToken); + + log.info("Zoom Webhook URL 검증 성공"); + return new ZoomWebhookValidationResponse(plainToken, encryptedToken); + } + + public ZoomWebhookValidationResponse handleWebhookEvents(ZoomWebhookEvent event) { + ZoomWebhookEvent.WebhookParticipant participant = + event.getPayload().getObject().getParticipant(); + + String participantUuid = participant.getParticipantUuid(); + String participantUuidLogMsg = + participantUuid != null ? participantUuid.substring(0, 8) + "..." : "없음"; + + /* 참가자 입장 이벤트 처리 */ + if (ZoomWebhookEventType.isParticipantJoined(event.getEventName())) { + // 소회의실 입장 여부 확인을 위한 디버깅 로그 + log.debug( + "입장 이벤트 상세 - participantId: {}, participantUuid: {}, joinTime: {}", + participant.getParticipantId(), + participantUuidLogMsg, + participant.getJoinTime()); + + zoomAttendanceService.handleParticipantJoined(participant); + log.info( + "참가자 입장: {} (uuid: {}, 시간: {})", + participant.getUserName(), + participantUuidLogMsg, + participant.getJoinTime() != null ? participant.getJoinTime() : "없음"); + } + + /* 참가자 퇴장 이벤트 처리 */ + if (ZoomWebhookEventType.isParticipantLeft(event.getEventName())) { + String leaveReason = participant.getLeaveReason(); + + log.info( + "참가자 퇴장: {} (uuid: {}, 시간: {}, 사유: {})", + participant.getUserName(), + participantUuidLogMsg, + participant.getLeaveTime() != null ? participant.getLeaveTime() : "없음", + leaveReason != null ? leaveReason : "없음"); + + // 도메인 서비스 호출하여 출석 데이터 저장 + zoomAttendanceService.handleParticipantLeft(participant); + } + return new ZoomWebhookValidationResponse(null, null); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/service/ProjectMemberServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/service/ProjectMemberServiceTest.java index 8a4e8515..f8258ad4 100644 --- a/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/service/ProjectMemberServiceTest.java +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/service/ProjectMemberServiceTest.java @@ -305,8 +305,9 @@ void acceptApplicantThrowsExceptionWhenNotFound() { @DisplayName("실패: 이미 APPROVED 상태인 경우 PENDING으로 조회되지 않아 예외가 발생한다") void acceptApplicantThrowsExceptionWhenAlreadyApproved() { // Given - given(projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( - teamId, userId, StatusCategory.PENDING)) + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) .willReturn(Optional.empty()); // When & Then @@ -314,8 +315,8 @@ void acceptApplicantThrowsExceptionWhenAlreadyApproved() { .isInstanceOf(ProjectMemberNotFoundException.class); // Verify: 서비스가 실제로 'PENDING' 상태를 조건으로 조회를 시도했는지 검증 (이 부분이 추가되어 중복 해결) - verify(projectMemberRepository).findByProjectTeamIdAndUserIdAndStatus( - teamId, userId, StatusCategory.PENDING); + verify(projectMemberRepository) + .findByProjectTeamIdAndUserIdAndStatus(teamId, userId, StatusCategory.PENDING); } } @@ -364,8 +365,9 @@ void rejectApplicantThrowsExceptionWhenNotFound() { @DisplayName("실패: 이미 REJECT 상태인 경우 PENDING으로 조회되지 않아 예외가 발생한다") void rejectApplicantThrowsExceptionWhenAlreadyRejected() { // Given - given(projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( - teamId, userId, StatusCategory.PENDING)) + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) .willReturn(Optional.empty()); // When & Then @@ -373,8 +375,8 @@ void rejectApplicantThrowsExceptionWhenAlreadyRejected() { .isInstanceOf(ProjectMemberNotFoundException.class); // Verify: PENDING 상태로 조회를 시도했는지 호출 여부를 검증하여 코드 중복 해결 - verify(projectMemberRepository).findByProjectTeamIdAndUserIdAndStatus( - teamId, userId, StatusCategory.PENDING); + verify(projectMemberRepository) + .findByProjectTeamIdAndUserIdAndStatus(teamId, userId, StatusCategory.PENDING); } }