diff --git a/src/main/java/com/chukchuk/haksa/domain/graduation/repository/GraduationQueryRepository.java b/src/main/java/com/chukchuk/haksa/domain/graduation/repository/GraduationQueryRepository.java index 158c7792..414e5552 100644 --- a/src/main/java/com/chukchuk/haksa/domain/graduation/repository/GraduationQueryRepository.java +++ b/src/main/java/com/chukchuk/haksa/domain/graduation/repository/GraduationQueryRepository.java @@ -5,6 +5,8 @@ import com.chukchuk.haksa.domain.graduation.dto.AreaRequirementDto; import com.chukchuk.haksa.domain.graduation.dto.CourseDto; import com.chukchuk.haksa.domain.graduation.dto.CourseInternalDto; +import com.chukchuk.haksa.global.exception.code.ErrorCode; +import com.chukchuk.haksa.global.exception.type.CommonException; import com.chukchuk.haksa.global.logging.annotation.LogTime; import com.chukchuk.haksa.infrastructure.redis.RedisCacheStore; import jakarta.persistence.EntityManager; @@ -144,15 +146,30 @@ public List getDualMajorAreaProgress(UUID studentId, Long prima // 주전공 졸업 요건 조회 List primaryReqs = getAreaRequirementsWithCache(primaryMajorId, admissionYear); - // 주전공 졸업 요건 중 '전선' 제외 + // 주전공 졸업 요건 데이터 부재 시 404 예외 처리 + if (primaryReqs == null || primaryReqs.isEmpty()) { + throw new CommonException(ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND); + } + + // 주전공 졸업 요건 중 전선/일선 제외 List primaryFiltered = primaryReqs.stream() .filter(req -> !req.areaType().equalsIgnoreCase(AREA_MAJOR_ELECTIVE)) .filter(req -> !req.areaType().equalsIgnoreCase(AREA_GENERAL_ELECTIVE)) .toList(); - // 복수전공 및 주전공 전선1 졸업 요건 조회 + // 필터링 후 빈 리스트가 되는 경우를 대비한 방어 코드 + if (primaryFiltered.isEmpty()) { + throw new CommonException(ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND); + } + + // 복수전공 졸업 요건 조회 List dualMajorReqs = getDualMajorRequirementsWithCache(primaryMajorId, secondaryMajorId, admissionYear); + // 복수 전공 졸업 요건 데이터 부재 시 404 예외 처리 + if (dualMajorReqs == null || dualMajorReqs.isEmpty()) { + throw new CommonException(ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND); + } + // 전체 병합 List mergedRequirements = new ArrayList<>(); mergedRequirements.addAll(primaryFiltered); // 주전공 졸업 요건 (전선 제외) diff --git a/src/main/java/com/chukchuk/haksa/domain/graduation/service/GraduationService.java b/src/main/java/com/chukchuk/haksa/domain/graduation/service/GraduationService.java index b72db30f..9b058d82 100644 --- a/src/main/java/com/chukchuk/haksa/domain/graduation/service/GraduationService.java +++ b/src/main/java/com/chukchuk/haksa/domain/graduation/service/GraduationService.java @@ -1,14 +1,16 @@ package com.chukchuk.haksa.domain.graduation.service; -import com.chukchuk.haksa.domain.department.model.Department; import com.chukchuk.haksa.domain.graduation.dto.AreaProgressDto; import com.chukchuk.haksa.domain.graduation.dto.GraduationProgressResponse; import com.chukchuk.haksa.domain.graduation.repository.GraduationQueryRepository; import com.chukchuk.haksa.domain.student.model.Student; import com.chukchuk.haksa.domain.student.service.StudentService; +import com.chukchuk.haksa.global.exception.code.ErrorCode; +import com.chukchuk.haksa.global.exception.type.CommonException; import com.chukchuk.haksa.infrastructure.redis.RedisCacheStore; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -45,25 +47,18 @@ public GraduationProgressResponse getGraduationProgress(UUID studentId) { } Student student = studentService.getStudentById(studentId); - Department dept = student.getDepartment(); - // 전공 코드가 없는 학과도 있으므로 majorId가 없으면 departmentId를 사용 - Long primaryMajorId = student.getMajor() != null ? student.getMajor().getId() : dept.getId(); + validateTransferStudent(student); + + Long primaryMajorId = resolvePrimaryMajorId(student); int admissionYear = student.getAcademicInfo().getAdmissionYear(); - List areaProgress = null; - if (student.getSecondaryMajor() != null) { // 복수전공 존재 - Long secondaryMajorId = student.getSecondaryMajor().getId(); - areaProgress = graduationQueryRepository.getDualMajorAreaProgress(studentId, primaryMajorId, secondaryMajorId, admissionYear); - } else { - // 졸업 요건 충족 여부 조회 - areaProgress = graduationQueryRepository.getStudentAreaProgress(studentId, primaryMajorId, admissionYear); - } + List areaProgress = + resolveAreaProgress(student, studentId, primaryMajorId, admissionYear); GraduationProgressResponse response = new GraduationProgressResponse(areaProgress); if (isDifferentGradRequirement(primaryMajorId, admissionYear)) { response.setHasDifferentGraduationRequirement(); - log.info("[BIZ] graduation.progress.flag.set studentId={} deptId={} year={}", studentId, primaryMajorId, admissionYear); } try { @@ -75,7 +70,110 @@ public GraduationProgressResponse getGraduationProgress(UUID studentId) { return response; } + // 편입생인 경우 예외 처리, TODO: 편입생 졸업 요건 추가 후 삭제 + private void validateTransferStudent(Student student) { + if (student.isTransferStudent()) { + throw new CommonException(ErrorCode.TRANSFER_STUDENT_UNSUPPORTED); + } + } + + private Long resolvePrimaryMajorId(Student student) { + return student.getMajor() != null + ? student.getMajor().getId() + : student.getDepartment().getId(); + } + private boolean isDifferentGradRequirement(Long departmentId, int admissionYear) { return admissionYear == SPECIAL_YEAR && departmentId != null && SPECIAL_DEPTS.contains(departmentId); } + + private List resolveAreaProgress( + Student student, + UUID studentId, + Long primaryMajorId, + int admissionYear + ) { + if (student.getSecondaryMajor() != null) { + return getDualMajorProgressOrThrow( + student, studentId, primaryMajorId, admissionYear + ); + } + return getSingleMajorProgressOrThrow( + student, studentId, primaryMajorId, admissionYear + ); + } + + // 단일 전공 처리 메서드 + private List getSingleMajorProgressOrThrow( + Student student, + UUID studentId, + Long departmentId, + int admissionYear + ) { + List result = + graduationQueryRepository.getStudentAreaProgress( + studentId, departmentId, admissionYear + ); + + if (result.isEmpty()) { + throwGraduationRequirementNotFound( + student, + departmentId, + null, + admissionYear); + } + + return result; + } + + // 복수 전공 처리 메서드 + private List getDualMajorProgressOrThrow( + Student student, + UUID studentId, + Long primaryMajorId, + int admissionYear + ) { + Long secondaryMajorId = student.getSecondaryMajor().getId(); + + try { + return graduationQueryRepository.getDualMajorAreaProgress( + studentId, primaryMajorId, secondaryMajorId, admissionYear + ); + } catch (CommonException e) { + if (ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND.code().equals(e.getCode())) { + throwGraduationRequirementNotFound( + student, + primaryMajorId, + secondaryMajorId, + admissionYear + ); + } + throw e; + } + } + + /** + * 졸업 요건 부재 예외 처리 메서드 + * MDC 정리는 GlobalExceptionHandler에서 수행됨 + */ + private void throwGraduationRequirementNotFound( + Student student, + Long primaryMajorId, + Long secondaryMajorId, + int admissionYear + ) { + MDC.put("student_code", student.getStudentCode()); + MDC.put("admission_year", String.valueOf(admissionYear)); + MDC.put("primary_department_id", String.valueOf(primaryMajorId)); + + if (secondaryMajorId == null) { + MDC.put("major_type", "SINGLE"); + MDC.put("secondary_department_id", "NONE"); + } else { + MDC.put("major_type", "DUAL"); + MDC.put("secondary_department_id", String.valueOf(secondaryMajorId)); + } + + throw new CommonException(ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND); + } } diff --git a/src/main/java/com/chukchuk/haksa/domain/student/model/Student.java b/src/main/java/com/chukchuk/haksa/domain/student/model/Student.java index 00c4836a..deca6928 100644 --- a/src/main/java/com/chukchuk/haksa/domain/student/model/Student.java +++ b/src/main/java/com/chukchuk/haksa/domain/student/model/Student.java @@ -137,6 +137,21 @@ public void updateInfo(String name, Department department, Department major, Dep .build(); } + public boolean isTransferStudent() { + if (this.studentCode == null || this.academicInfo == null || this.academicInfo.getAdmissionYear() == null) { + return false; + } + + if (this.studentCode.length() < 2) { + return false; + } + + String codePrefix = this.studentCode.substring(0, 2); // 학번 앞 2자리 + String yearSuffix = String.valueOf(this.academicInfo.getAdmissionYear()).substring(2); // 입학년도 뒤 2자리 + + return !codePrefix.equals(yearSuffix); + } + public void addStudentCourse(StudentCourse course) { this.studentCourses.add(course); course.setStudent(this); diff --git a/src/main/java/com/chukchuk/haksa/global/exception/code/ErrorCode.java b/src/main/java/com/chukchuk/haksa/global/exception/code/ErrorCode.java index 96e79873..3417a5dd 100644 --- a/src/main/java/com/chukchuk/haksa/global/exception/code/ErrorCode.java +++ b/src/main/java/com/chukchuk/haksa/global/exception/code/ErrorCode.java @@ -4,6 +4,20 @@ public enum ErrorCode { + // 공통(Common) + INVALID_ARGUMENT("C01", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST), + NOT_FOUND("C05", "요청한 API를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + + // 인증 및 세션 관련 + SESSION_EXPIRED("A04", "로그인 세션이 만료되었습니다.", HttpStatus.UNAUTHORIZED), + // 인증 및 세션 관련 + AUTHENTICATION_REQUIRED("A05", "인증이 필요한 요청입니다.", HttpStatus.UNAUTHORIZED), + + // 서버 오류 관련 + SCRAPING_FAILED("C02", "포털 크롤링 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + REFRESH_FAILED("C03", "포털 정보 재연동 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + FORBIDDEN("C04", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + // Token 관련 TOKEN_INVALID_FORMAT("T01", "ID 토큰 형식이 올바르지 않습니다.", HttpStatus.UNAUTHORIZED), TOKEN_NO_MATCHING_KEY("T02", "일치하는 공개키가 없습니다.", HttpStatus.UNAUTHORIZED), @@ -23,36 +37,27 @@ public enum ErrorCode { STUDENT_ACADEMIC_RECORD_NOT_FOUND("U02", "해당 학생의 학적 정보가 존재하지 않습니다.", HttpStatus.NOT_FOUND), USER_ALREADY_CONNECTED("U03", "이미 포털과 연동된 사용자입니다.", HttpStatus.BAD_REQUEST), USER_NOT_CONNECTED("U04", "아직 포털과 연동되지 않은 사용자입니다.", HttpStatus.BAD_REQUEST), + + // Student 관련 STUDENT_NOT_FOUND("S01", "해당 학생이 존재하지 않습니다.", HttpStatus.NOT_FOUND), INVALID_TARGET_GPA("S02", "유효하지 않은 목표 학점입니다.", HttpStatus.BAD_REQUEST), STUDENT_ID_REQUIRED("S03", "Student ID는 필수입니다.", HttpStatus.BAD_REQUEST), + TRANSFER_STUDENT_UNSUPPORTED("T13", "편입생 학적 정보는 현재 지원되지 않습니다.", HttpStatus.UNPROCESSABLE_ENTITY), // 학업 관련 SEMESTER_RECORD_NOT_FOUND("A01", "해당 학기의 성적 데이터를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), SEMESTER_RECORD_EMPTY("A02", "학기 성적 데이터를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), FRESHMAN_NO_SEMESTER("A03", "신입생은 학기 기록이 없습니다.", HttpStatus.BAD_REQUEST), - GRADUATION_REQUIREMENTS_NOT_FOUND("G01", "졸업 요건 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - // 학업 관련 INVALID_GRADE_TYPE("A06", "존재하지 않는 성적 등급입니다.", HttpStatus.BAD_REQUEST), + // 졸업 요건 관련 + GRADUATION_REQUIREMENTS_NOT_FOUND("G01", "졸업 요건 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + GRADUATION_REQUIREMENTS_DATA_NOT_FOUND("G02", "사용자에게 맞는 졸업 요건 데이터가 존재하지 않습니다.", HttpStatus.NOT_FOUND), + // 포털 관련 PORTAL_LOGIN_FAILED("P01", "아이디나 비밀번호가 일치하지 않습니다.\n학교 홈페이지에서 확인해주세요.", HttpStatus.UNAUTHORIZED), PORTAL_SCRAPE_FAILED("P02", "포털 크롤링 실패", HttpStatus.INTERNAL_SERVER_ERROR), - PORTAL_ACCOUNT_LOCKED("P03", "계정이 잠겼습니다. 포털사이트로 돌아가서 학번/사번 찾기 및 비밀번호 재발급을 진행해주세요", HttpStatus.LOCKED), - - // 공통(Common) - INVALID_ARGUMENT("C01", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST), - NOT_FOUND("C05", "요청한 API를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - - // 인증 및 세션 관련 - SESSION_EXPIRED("A04", "로그인 세션이 만료되었습니다.", HttpStatus.UNAUTHORIZED), - // 인증 및 세션 관련 - AUTHENTICATION_REQUIRED("A05", "인증이 필요한 요청입니다.", HttpStatus.UNAUTHORIZED), - - // 서버 오류 관련 - SCRAPING_FAILED("C02", "포털 크롤링 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - REFRESH_FAILED("C03", "포털 정보 재연동 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - FORBIDDEN("C04", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN); + PORTAL_ACCOUNT_LOCKED("P03", "계정이 잠겼습니다. 포털사이트로 돌아가서 학번/사번 찾기 및 비밀번호 재발급을 진행해주세요", HttpStatus.LOCKED); private final String code; private final String message; diff --git a/src/main/java/com/chukchuk/haksa/global/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/chukchuk/haksa/global/exception/handler/GlobalExceptionHandler.java index 521c9dc8..8338d634 100644 --- a/src/main/java/com/chukchuk/haksa/global/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/chukchuk/haksa/global/exception/handler/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import com.chukchuk.haksa.global.exception.type.BaseException; import com.chukchuk.haksa.global.exception.type.EntityNotFoundException; import com.chukchuk.haksa.global.logging.sanitize.LogSanitizer; +import io.sentry.IScope; import io.sentry.Sentry; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; @@ -34,12 +35,28 @@ public ResponseEntity handleBase(BaseException ex, HttpServletReq scope.setTag("error.code", ex.getCode()); scope.setFingerprint(List.of("BASE_EXCEPTION", ex.getCode())); scope.setLevel(io.sentry.SentryLevel.WARNING); // 4xx 의미 유지 + + // MDC → Sentry Tag 승격 (있을 때만) + putIfPresent(scope, "student_code"); + putIfPresent(scope, "admission_year"); + putIfPresent(scope, "primary_department_id"); + putIfPresent(scope, "secondary_department_id"); + putIfPresent(scope, "major_type"); + Sentry.captureException(ex); }); return ResponseEntity.status(ex.getStatus()) .body(ErrorResponse.of(ex.getCode(), ex.getMessage(), null)); } + + private void putIfPresent(IScope scope, String key) { + String value = MDC.get(key); + if (value != null) { + scope.setTag(key, value); + } + } + /** 404 */ @ExceptionHandler(org.springframework.web.servlet.NoHandlerFoundException.class) public ResponseEntity handleNoHandler( diff --git a/src/main/java/com/chukchuk/haksa/global/logging/filter/MdcCleanupFilter.java b/src/main/java/com/chukchuk/haksa/global/logging/filter/MdcCleanupFilter.java new file mode 100644 index 00000000..37761562 --- /dev/null +++ b/src/main/java/com/chukchuk/haksa/global/logging/filter/MdcCleanupFilter.java @@ -0,0 +1,31 @@ +package com.chukchuk.haksa.global.logging.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@Order(Ordered.LOWEST_PRECEDENCE) +public class MdcCleanupFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } finally { + MDC.clear(); // 요청 종료 지점 + } + } +}