Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
cfd4edb
setting: 시큐리티 테스트 디펜던시 추가
yuripbong Nov 29, 2025
3397301
test: stack 도메인 단위 테스트
yuripbong Nov 29, 2025
51415f6
feat: projectTeam entity testcode
yuripbong Dec 1, 2025
78eee86
test: projectTeam controller testCode
yuripbong Dec 1, 2025
388fd11
test: projectTeam Service testcode
yuripbong Dec 1, 2025
aa2484b
zoom header debugging
dongwooooooo Dec 1, 2025
8dd61d1
zoom testConnection 체
dongwooooooo Dec 1, 2025
0b4675f
test: projectTeam repository testCode
yuripbong Dec 1, 2025
6eb9033
style: spotless 적용
yuripbong Dec 5, 2025
0ceb14b
test: mokito 설정
yuripbong Dec 5, 2025
d885e71
test: stack 실패 테스트 추가
yuripbong Dec 5, 2025
b5deb82
test: 실패 테스트 추가 및 디스플레이 멘트 수정
yuripbong Dec 5, 2025
b7e86fd
refactor: @PreAuthorize 적용
yuripbong Dec 5, 2025
5b09311
style: spotless
yuripbong Dec 5, 2025
e79f956
zoom event notification endpoint url validate api
dongwooooooo Dec 6, 2025
ebffde4
zoom event notification endpoint url validate api
dongwooooooo Dec 6, 2025
11ac775
zoom event notification endpoint url validate api
dongwooooooo Dec 6, 2025
7c4744c
zoom event notification endpoint url validate api
dongwooooooo Dec 6, 2025
55aaf53
zoom 안 쓰는 코드 정리
dongwooooooo Dec 6, 2025
800d96a
zoom 안 쓰는 코드 정리
dongwooooooo Dec 6, 2025
042258d
zoom api 토큰 설정
dongwooooooo Dec 6, 2025
0523e24
zoom api 토큰 설정
dongwooooooo Dec 6, 2025
8a398df
zoom 웹훅 api 수정 및 정리
dongwooooooo Dec 6, 2025
fc22dee
zoom webhook exception
dongwooooooo Dec 6, 2025
59ec0d9
spotlessApply
dongwooooooo Dec 6, 2025
276a716
zoomWebhook 인증 리팩터링
dongwooooooo Dec 6, 2025
9694580
spotlessApply
dongwooooooo Dec 6, 2025
a080e48
래빗 리뷰
dongwooooooo Dec 6, 2025
86c87ef
zoom 인증 경로 설정
dongwooooooo Dec 7, 2025
f953881
zoom 웹훅 이벤트 body 실종 수정
dongwooooooo Dec 7, 2025
5591e9a
spotlessApply
dongwooooooo Dec 7, 2025
be72a68
zoom 인증 경로 설정
dongwooooooo Dec 7, 2025
5213a47
zoomWebhook filter에서 aop로 인증 변경
dongwooooooo Dec 7, 2025
dc53254
spotlessApply
dongwooooooo Dec 7, 2025
0b812e2
zoom 12시 이후로 나가면 조회 못하는 에러 해결
dongwooooooo Dec 10, 2025
02288ea
spotlessApply
dongwooooooo Dec 10, 2025
695f534
Merge pull request #132 from Techeer-Hogwarts/BACKEND-193
dongwooooooo Dec 10, 2025
8f5c10b
feat: 부트캠프 목록 조회 응답값 수정
kimzini Dec 21, 2025
adaa34b
feat: 부트캠프 목록 조회 응답값 수정
kimzini Dec 21, 2025
15522d7
test: stack 도메인 단위 테스트
yuripbong Nov 29, 2025
4a2fcc4
feat: projectTeam entity testcode
yuripbong Dec 1, 2025
4104ea3
test: projectTeam controller testCode
yuripbong Dec 1, 2025
38c3767
test: projectTeam Service testcode
yuripbong Dec 1, 2025
5c169c8
test: projectTeam repository testCode
yuripbong Dec 1, 2025
7d45b5c
style: spotless 적용
yuripbong Dec 5, 2025
7e32117
test: mokito 설정
yuripbong Dec 5, 2025
646970e
test: stack 실패 테스트 추가
yuripbong Dec 5, 2025
5a8fff8
test: 실패 테스트 추가 및 디스플레이 멘트 수정
yuripbong Dec 5, 2025
60e5c5b
refactor: @PreAuthorize 적용
yuripbong Dec 5, 2025
8ecd15e
style: spotless
yuripbong Dec 5, 2025
044217f
chore: 주석 수정
yuripbong Jan 5, 2026
8b88584
test: preAuthorize 테스트 추가
yuripbong Jan 5, 2026
385cf49
test: 레포지토리 슬라이스 테스트
yuripbong Jan 6, 2026
dfcbde6
chore: TeamStack 테이블 isMain 추가
yuripbong Jan 6, 2026
28c25d0
chore: 충돌 해결
yuripbong Jan 6, 2026
bfd8dfe
test: 테스트용 메소드 시큐리티 설정
yuripbong Jan 6, 2026
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
Expand Up @@ -20,6 +20,8 @@ public class BootcampListResponse {
@Builder
public static class BootcampListItem {
private Long id;
private String name;
private String projectExplain;
private Integer year;
private String imageUrl;
private Integer rank;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ public static BootcampListResponse toListResponseWithUserGeneration(
private static BootcampListResponse.BootcampListItem toListItem(Bootcamp bootcamp) {
return BootcampListResponse.BootcampListItem.builder()
.id(bootcamp.getId())
.name(bootcamp.getName())
.projectExplain(bootcamp.getProjectExplain())
.year(bootcamp.getYear())
.imageUrl(bootcamp.getImageUrl())
.rank(bootcamp.getRank())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public ResponseEntity<ProjectTeamDetailResponse> getDetail(@PathVariable Long pr

@Override
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyRole('ADMIN', 'MENTOR', 'TECHEER')")
public ResponseEntity<Long> createProjectTeam(
@RequestPart(value = "mainImage") MultipartFile mainImage,
@RequestPart(value = "resultImages", required = false) List<MultipartFile> resultImages,
Expand All @@ -63,6 +64,7 @@ public ResponseEntity<Long> createProjectTeam(
}

@PatchMapping(value = "/{projectTeamId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasPermission(#projectTeamId, 'ProjectTeam', 'UPDATE')")
public ResponseEntity<Long> updateProjectTeam(
@PathVariable Long projectTeamId,
@RequestPart(value = "mainImage", required = false) MultipartFile mainImage,
Expand Down Expand Up @@ -99,6 +101,7 @@ public ResponseEntity<Void> closeRecruit(
}

@PatchMapping("/delete/{projectTeamId}")
@PreAuthorize("hasPermission(#projectTeamId, 'ProjectTeam', 'DELETE')")
public ResponseEntity<Void> deleteProjectTeam(
@PathVariable Long projectTeamId, @CurrentUser CustomUserPrincipal currentUser) {
log.info(
Expand All @@ -110,6 +113,7 @@ public ResponseEntity<Void> deleteProjectTeam(
}

@GetMapping("/{projectTeamId}/applicants")
@PreAuthorize("hasPermission(#projectTeamId, 'ProjectTeam', 'READ')")
public ResponseEntity<List<ProjectMemberApplicantResponse>> getApplicants(
@PathVariable Long projectTeamId, @CurrentUser Long userId) {
log.info(
Expand Down Expand Up @@ -144,6 +148,7 @@ public ResponseEntity<Void> cancelApplication(
}

@PatchMapping("/accept")
@PreAuthorize("hasPermission(#request.teamId(), 'ProjectTeam', 'UPDATE')")
public ResponseEntity<Void> acceptApplicant(
@RequestBody ProjectApplicantRequest request,
@CurrentUser CustomUserPrincipal currentUser) {
Expand All @@ -154,6 +159,7 @@ public ResponseEntity<Void> acceptApplicant(
}

@PatchMapping("/reject")
@PreAuthorize("hasPermission(#request.teamId(), 'ProjectTeam', 'UPDATE')")
public ResponseEntity<Void> rejectApplicant(
@RequestBody ProjectApplicantRequest request,
@CurrentUser CustomUserPrincipal currentUser) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import backend.techeerzip.domain.stack.dto.StackDto;
Expand All @@ -16,6 +17,7 @@ public class StackController {
private final StackService stackService;

@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> createStack(@RequestBody StackDto.Create request) {
stackService.create(request);
return ResponseEntity.ok().build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ Optional<ZoomAttendance> 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<ZoomAttendance> findActiveSessionByParticipantUuid(
@Param("participantUuid") String participantUuid);

/** 특정 사용자의 월별 통계 조회 */
@Query(
"SELECT new backend.techeerzip.domain.zoom.dto.response.ZoomMonthlyStatsDto("
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -21,69 +21,42 @@
@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;
}

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

// 기존 출석 기록 조회
Optional<ZoomAttendance> existingRecord =
zoomAttendanceRepository.findByParticipantUuidAndMeetingDate(
participantUuid, meetingDate);
// 활성 세션 조회 (leaveTime이 null인 기록)
// participant_uuid는 링크 접속 시 생성되고, 완전히 나갔다 다시 들어오면 새로 할당됨
// 따라서 같은 participant_uuid로 활성 세션이 있으면 = 소회의실에서 메인으로 복귀한 경우
Optional<ZoomAttendance> 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()
Expand All @@ -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<ZoomAttendance> existingRecord =
zoomAttendanceRepository.findByParticipantUuidAndMeetingDate(
participantUuid, meetingDate);
// 활성 세션 조회 (leaveTime이 null인 기록만)
// participantUuid만으로 조회하여 날짜 경계 문제 해결 (밤 11시 입장 → 다음 날 새벽 퇴장 케이스)
Optional<ZoomAttendance> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
@Configuration
public class WebConfig implements WebMvcConfigurer {

private OctetStreamReadMsgConverter octetStreamReadMsgConverter;
private final OctetStreamReadMsgConverter octetStreamReadMsgConverter;

@Override
public void addCorsMappings(CorsRegistry registry) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public ResponseEntity<ErrorResponse> handleValidationExceptions(
e.getBindingResult()
.getAllErrors()
.forEach(
(error) -> {
error -> {
if (error instanceof FieldError fieldError) {
String fieldName = fieldError.getField();
String errorMessage = error.getDefaultMessage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public boolean hasPermission(

private boolean isSupportedPermission(String permission) {
return switch (permission) {
case "CLOSE" -> true; // 나중에 permission 추가 시 수정
case "CLOSE", "UPDATE", "DELETE", "READ" -> true;
default -> false;
};
}
Expand Down
Loading
Loading