diff --git a/build.gradle b/build.gradle index fa277c1..c66c10a 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,14 @@ configurations { repositories { mavenCentral() + maven { + name = 'chuseok22NexusRelease' + url = uri('https://nexus.chuseok22.com/repository/maven-releases/') + metadataSources { + mavenPom() + artifact() + } + } } dependencies { @@ -52,6 +60,12 @@ dependencies { // Jsoup (HTML 파싱) implementation 'org.jsoup:jsoup:1.18.1' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'com.chuseok22:sejong-portal-login:1.0.0' + } tasks.named('test') { diff --git a/src/main/java/com/example/enjoy/controller/LoginController.java b/src/main/java/com/example/enjoy/controller/LoginController.java index 1ce7d89..c732c98 100644 --- a/src/main/java/com/example/enjoy/controller/LoginController.java +++ b/src/main/java/com/example/enjoy/controller/LoginController.java @@ -29,7 +29,7 @@ public class LoginController { public ResponseEntity loginAndGetUserInfo(@RequestBody @Valid MemberCommand command) { try { log.info("세종대 포털 로그인 요청: {}", command.getSejongPortalId()); - MemberDto memberInfo = sejongLoginService.getMemberAuthInfos(command); + MemberDto memberInfo = sejongLoginService.login(command); log.info("사용자 정보 조회 성공: {}", memberInfo.getStudentName()); return ResponseEntity.ok(memberInfo); } catch (Exception e) { diff --git a/src/main/java/com/example/enjoy/controller/UserController.java b/src/main/java/com/example/enjoy/controller/UserController.java index 0580f8e..080a590 100644 --- a/src/main/java/com/example/enjoy/controller/UserController.java +++ b/src/main/java/com/example/enjoy/controller/UserController.java @@ -1,6 +1,7 @@ package com.example.enjoy.controller; import com.example.enjoy.dto.AddManualCourseRequest; +import com.example.enjoy.dto.StudentCourseResponse; import com.example.enjoy.dto.StudentCourseStatus; import com.example.enjoy.dto.loginDto.MemberCommand; import com.example.enjoy.dto.loginDto.MemberDto; @@ -31,7 +32,7 @@ public UserController(SejongLoginService sejongLoginService, UserService userSer } @Operation(summary = "학생 정보 조회", description = "세종대학교 포털 인증을 통해 학생 정보를 조회합니다.") - @GetMapping("/detail") + @PostMapping("/detail") public ResponseEntity getStudentDetail(@RequestBody MemberCommand command) throws IOException { MemberDto memberInfo = sejongLoginService.getMemberAuthInfos(command); return ResponseEntity.ok(memberInfo); @@ -44,6 +45,48 @@ public ResponseEntity addManualCourse(@Valid @RequestBody AddManualCourseR return ResponseEntity.ok().build(); } + @Operation(summary = "수동 과목 조회", description = "학생이 수동으로 등록한 과목 목록을 조회합니다.") + @GetMapping("/{studentId}/courses/manual") + public ResponseEntity> getManualCourses(@PathVariable String studentId) { + List manualCourses = userService.getManualCourses(studentId); + if (manualCourses.isEmpty()) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok( + manualCourses.stream() + .map(course -> new StudentCourseResponse(course.getCourseName(), course.getStatus())) + .toList() + ); + } + + @Operation(summary = "진행 예정 과목 조회", description = "학생이 수강 예정인 과목 목록을 조회합니다.") + @GetMapping("/{studentId}/courses/planned") + public ResponseEntity> getPlannedCourses(@PathVariable String studentId) { + List plannedCourses = userService.getPlannedCourses(studentId); + if (plannedCourses.isEmpty()) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok( + plannedCourses.stream() + .map(course -> new StudentCourseResponse(course.getCourseName(), course.getStatus())) + .toList() + ); + } + + @Operation(summary = "수강 중인 과목 조회", description = "학생이 현재 수강 중인 과목 목록을 조회합니다.") + @GetMapping("/{studentId}/courses/inprogress") + public ResponseEntity> getInProgressCourses(@PathVariable String studentId) { + List inProgressCourses = userService.getInProgressCourses(studentId); + if (inProgressCourses.isEmpty()) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok( + inProgressCourses.stream() + .map(course -> new StudentCourseResponse(course.getCourseName(), course.getStatus())) + .toList() + ); + } + @Operation(summary = "수동 과목 삭제", description = "수동으로 등록한 과목을 삭제합니다.") @DeleteMapping("/courses") public ResponseEntity removeManualCourse( diff --git a/src/main/java/com/example/enjoy/dto/StudentCourseResponse.java b/src/main/java/com/example/enjoy/dto/StudentCourseResponse.java new file mode 100644 index 0000000..884de20 --- /dev/null +++ b/src/main/java/com/example/enjoy/dto/StudentCourseResponse.java @@ -0,0 +1,30 @@ +package com.example.enjoy.dto; + +import com.example.enjoy.entity.StudentCourse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "학생 수강 과목 응답") +public class StudentCourseResponse { + + @Schema(description = "과목명") + private String courseName; + + @Schema(description = "이수 상태") + private StudentCourseStatus status; + + public StudentCourseResponse(String courseName, StudentCourseStatus status) { + this.courseName = courseName; + this.status = status; + } + + public static StudentCourseResponse from(StudentCourse entity) { + return new StudentCourseResponse( + entity.getCourseName(), + entity.getStatus() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/enjoy/dto/loginDto/MemberDto.java b/src/main/java/com/example/enjoy/dto/loginDto/MemberDto.java index 03a23a6..c18a4d5 100644 --- a/src/main/java/com/example/enjoy/dto/loginDto/MemberDto.java +++ b/src/main/java/com/example/enjoy/dto/loginDto/MemberDto.java @@ -9,7 +9,7 @@ public class MemberDto { private String major; private String studentIdString; private String studentName; - private String academicYear; - private String enrollmentStatus; + private String grade; + private String completedSemester; } diff --git a/src/main/java/com/example/enjoy/exception/ErrorCode.java b/src/main/java/com/example/enjoy/exception/ErrorCode.java index 4f38c87..2128134 100644 --- a/src/main/java/com/example/enjoy/exception/ErrorCode.java +++ b/src/main/java/com/example/enjoy/exception/ErrorCode.java @@ -17,6 +17,7 @@ public enum ErrorCode { SEJONG_AUTH_CREDENTIALS_INVALID(HttpStatus.UNAUTHORIZED, "세종대학교 인증 정보가 유효하지 않습니다"), SEJONG_AUTH_DATA_FETCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "세종대학교 인증 데이터 가져오기 실패"); + private final String message; private final int status; diff --git a/src/main/java/com/example/enjoy/repository/StudentCourseRepository.java b/src/main/java/com/example/enjoy/repository/StudentCourseRepository.java index 2320076..1154894 100644 --- a/src/main/java/com/example/enjoy/repository/StudentCourseRepository.java +++ b/src/main/java/com/example/enjoy/repository/StudentCourseRepository.java @@ -22,4 +22,6 @@ public interface StudentCourseRepository extends JpaRepository findByStudentIdAndCourseName(String studentId, String courseName); + List findAllByStudentIdAndManualIsTrue(String studentId); + } \ No newline at end of file diff --git a/src/main/java/com/example/enjoy/service/loginService/SejongLoginService.java b/src/main/java/com/example/enjoy/service/loginService/SejongLoginService.java index a8fae12..049d017 100644 --- a/src/main/java/com/example/enjoy/service/loginService/SejongLoginService.java +++ b/src/main/java/com/example/enjoy/service/loginService/SejongLoginService.java @@ -1,6 +1,8 @@ package com.example.enjoy.service.loginService; +import com.chuseok22.sejongportallogin.core.SejongMemberInfo; +import com.chuseok22.sejongportallogin.infrastructure.SejongPortalLoginService; import com.example.enjoy.dto.loginDto.MemberCommand; import com.example.enjoy.dto.loginDto.MemberDto; import com.example.enjoy.exception.CustomException; @@ -26,254 +28,34 @@ @RequiredArgsConstructor public class SejongLoginService { - /** - * 세종포털 인증을 통해 사용자 정보를 가져옵니다 - */ - public MemberDto getMemberAuthInfos(MemberCommand command) { - String sejongPortalId = command.getSejongPortalId(); - String sejongPortalPw = command.getSejongPortalPassword(); - - try { - // OkHttpClient 생성 - OkHttpClient client = buildClient(); - - // 포털 로그인 요청 - doPortalLogin(client, sejongPortalId, sejongPortalPw); - - // 포털 -> 고전독서 SSO 시작점 접근 - String portalClassicLinkUrl = "https://portal.sejong.ac.kr/html/classic/classic.html"; - Request portalReq = new Request.Builder() - .url(portalClassicLinkUrl) - .get() - .build(); - try (Response portalResp = executeWithRetry(client, portalReq)) { - log.debug("포털 링크 응답: {}", portalResp.code()); - } - - // SSO 리다이렉트 처리 - String ssoUrl = "https://classic.sejong.ac.kr/_custom/sejong/sso/sso-return.jsp?returnUrl=https://classic.sejong.ac.kr/classic/index.do"; - Request ssoReq = new Request.Builder() - .url(ssoUrl) - .get() - .header("Referer", "https://portal.sejong.ac.kr/") - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .build(); - - try (Response ssoResp = executeWithRetry(client, ssoReq)) { - log.debug("SSO 응답: {} - 쿠키 수: {}", - ssoResp.code(), - client.cookieJar().loadForRequest(HttpUrl.get(ssoUrl)).size()); - - if (!ssoResp.isSuccessful()) { - throw new CustomException(ErrorCode.SEJONG_AUTH_CONNECTION_ERROR, "SSO 리다이렉트 실패"); - } - } - - // 메인 페이지 방문 (중요: 세션 쿠키 설정을 위해) - String mainUrl = "https://classic.sejong.ac.kr/classic/index.do"; - Request mainReq = new Request.Builder() - .url(mainUrl) - .get() - .header("Referer", ssoUrl) - .build(); - - try (Response mainResp = executeWithRetry(client, mainReq)) { - log.debug("메인 페이지 응답: {}", mainResp.code()); - if (!mainResp.isSuccessful()) { - throw new CustomException(ErrorCode.SEJONG_AUTH_CONNECTION_ERROR, "메인 페이지 접근 실패"); - } - } - - // 고전독서인증현황 페이지 GET - String html = fetchReadingStatusHtml(client); - - // HTML 파싱 및 정보 추출 - return parseHTMLAndGetMemberInfo(html); - - } catch (IOException e) { - log.error("포털 인증 중 오류 발생: {}", e.getMessage(), e); - throw new CustomException(ErrorCode.SEJONG_AUTH_CONNECTION_ERROR, "포털 인증 중 오류 발생"); - } - } - - - /** - * 세종포털에 ID/PW로 로그인 - */ - private void doPortalLogin(OkHttpClient client, String studentId, String password) throws IOException { - String loginUrl = "https://portal.sejong.ac.kr/jsp/login/login_action.jsp"; - - RequestBody formBody = new FormBody.Builder() - .add("mainLogin", "N") - .add("rtUrl", "") // 빈 값으로 변경하여 리디렉션 동작 확인 - .add("id", studentId) - .add("password", password) - .build(); - - Request request = new Request.Builder() - .url(loginUrl) - .post(formBody) - .header("Host", "portal.sejong.ac.kr") - .header("Origin", "https://portal.sejong.ac.kr") - .header("Referer", "https://portal.sejong.ac.kr/jsp/login/login.jsp") - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .build(); - - try (Response response = executeWithRetry(client, request)) { - log.debug("포털 로그인 응답: {}", response.code()); - String responseBody = response.body() != null ? response.body().string() : ""; - - if (responseBody.contains("alert") && responseBody.contains("로그인")) { - throw new CustomException(ErrorCode.SEJONG_AUTH_CREDENTIALS_INVALID, "포털 로그인 실패"); - } - } - } - - /** - * 고전독서인증현황 페이지 HTML 가져오기 - */ - private String fetchReadingStatusHtml(OkHttpClient client) throws IOException { - String statusUrl = "https://classic.sejong.ac.kr/classic/reading/status.do"; - - // 요청 전 쿠키 상태 확인 - logCookies(client, statusUrl); - - Request request = new Request.Builder() - .url(statusUrl) - .get() - .header("Referer", "https://classic.sejong.ac.kr/classic/index.do") - .build(); - - try (Response response = client.newCall(request).execute()) { - log.debug("고전독서인증현황 응답: {}", response.code()); - - if (response.code() != 200 || response.body() == null) { - throw new CustomException(ErrorCode.SEJONG_AUTH_DATA_FETCH_ERROR, - "고전독서인증현황 페이지 조회 실패: " + response); - } - - return response.body().string(); - } - } - - /** - * 디버깅용: 쿠키 정보 출력 - */ - private void logCookies(OkHttpClient client, String url) { - List cookies = client.cookieJar().loadForRequest(HttpUrl.get(url)); - log.debug("URL {} 쿠키 정보 ({}):", url, cookies.size()); - for (Cookie cookie : cookies) { - log.debug(" - {}: {}", cookie.name(), cookie.value()); - } - } - - /** - * 고전독서인증현황 페이지 파싱 - */ - private MemberDto parseHTMLAndGetMemberInfo(String html) { - Document doc = Jsoup.parse(html); - - String selector = ".b-con-box:has(h4.b-h4-tit01:contains(사용자 정보)) table.b-board-table tbody tr"; - List rowValues = new ArrayList<>(); - - doc.select(selector).forEach(tr -> { - String value = tr.select("td").text().trim(); - rowValues.add(value); - }); - - String major = getValueFromList(rowValues, 0); - String studentId = getValueFromList(rowValues, 1); - String studentName = getValueFromList(rowValues, 2); - String year = getValueFromList(rowValues, 3); - String status = getValueFromList(rowValues, 4); + private final SejongPortalLoginService sejongPortalLoginService; + public MemberDto login(MemberCommand memberCommand){ + SejongMemberInfo info = sejongPortalLoginService.getMemberAuthInfos(memberCommand.getSejongPortalId(), memberCommand.getSejongPortalPassword()); return MemberDto.builder() - .major(major) - .studentIdString(studentId) - .studentName(studentName) - .academicYear(year) - .enrollmentStatus(status) + .major(info.getMajor()) + .studentIdString(info.getStudentId()) + .studentName(info.getName()) + .grade(info.getGrade()) + .completedSemester(info.getCompletedSemester()) .build(); } - /** - * List에서 안전하게 값 가져오기 - */ - private String getValueFromList(List list, int index) { - return list.size() > index ? list.get(index) : null; - } - - /** - * 재시도 로직이 포함된 요청 실행 - */ - private Response executeWithRetry(OkHttpClient client, Request request) throws IOException { - int tryCount = 0; - int maxRetries = 3; - - while (tryCount < maxRetries) { - try { - Response response = client.newCall(request).execute(); - if (response.isSuccessful() || tryCount == maxRetries - 1) { - return response; - } - response.close(); - } catch (SocketTimeoutException e) { - if (tryCount == maxRetries - 1) { - throw e; - } - } - tryCount++; - log.debug("요청 재시도 ({}/{}): {}", tryCount, maxRetries, request.url()); - } - throw new IOException("최대 재시도 횟수 초과: " + request.url()); - } - - private OkHttpClient buildClient() { + public MemberDto getMemberAuthInfos(MemberCommand memberCommand) throws IOException { try { - // SSLContext 생성, 모든 인증서 신뢰 설정 - SSLContext sslCtx = SSLContext.getInstance("SSL"); - sslCtx.init(null, new TrustManager[]{trustAllManager()}, new java.security.SecureRandom()); - SSLSocketFactory sslFactory = sslCtx.getSocketFactory(); - - // hostnameVerifier: 모든 호스트네임에 대해 OK 처리 - HostnameVerifier hostnameVerifier = (hostname, session) -> true; - - // OkHttp 로깅 인터셉터 -// HttpLoggingInterceptor logging = new HttpLoggingInterceptor(log::info); -// logging.setLevel(HttpLoggingInterceptor.Level.BODY); - - // 쿠키 관리 - CookieManager cookieManager = new CookieManager(); - cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); - - // OkHttpClient 생성 - return new OkHttpClient.Builder() - .sslSocketFactory(sslFactory, trustAllManager()) - .hostnameVerifier(hostnameVerifier) - .cookieJar(new JavaNetCookieJar(cookieManager)) -// .addInterceptor(logging) + SejongMemberInfo info = sejongPortalLoginService.getMemberAuthInfos(memberCommand.getSejongPortalId(), memberCommand.getSejongPortalPassword()); + return MemberDto.builder() + .major(info.getMajor()) + .studentIdString(info.getStudentId()) + .studentName(info.getName()) + .grade(info.getGrade()) + .completedSemester(info.getCompletedSemester()) .build(); - } catch (Exception e) { - throw new RuntimeException(e); + log.error("세종대학교 포털 로그인 정보 가져오기 실패: {}", e.getMessage()); + throw new CustomException(ErrorCode.SEJONG_AUTH_DATA_FETCH_ERROR); } } - - /** - * 모든 서버 인증서를 신뢰하는 X509TrustManager 구현 - */ - private X509TrustManager trustAllManager() { - return new X509TrustManager() { - @Override - public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) {} - @Override - public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) {} - @Override - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - return new java.security.cert.X509Certificate[0]; - } - }; - } } diff --git a/src/main/java/com/example/enjoy/service/userService/UserService.java b/src/main/java/com/example/enjoy/service/userService/UserService.java index aab1f1a..f7e91e2 100644 --- a/src/main/java/com/example/enjoy/service/userService/UserService.java +++ b/src/main/java/com/example/enjoy/service/userService/UserService.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -39,10 +40,19 @@ public void addManualCourse(AddManualCourseRequest request) { //수동으로 과 .courseName(request.getCourseName()) .status(request.getStatus()) .manual(true) + .createdAt(LocalDateTime.now()) .build(); studentCourseRepository.save(sc); } + public List getManualCourses(String studentId) { //수동 등록 과목 조회 + List manualCourses = studentCourseRepository.findAllByStudentIdAndManualIsTrue(studentId); + if (manualCourses.isEmpty()) { + throw new IllegalArgumentException("수동 등록된 과목이 없습니다."); + } + return manualCourses; + } + @Transactional public void removeManualCourse(String studentId, String courseName) { //수동 등록 과목 삭제 @@ -65,6 +75,16 @@ public Map getTrackProgress(String studentId) { //트랙별 진 )); } + public List getPlannedCourses(String studentId) { //수강 예정 과목 조회 + return studentCourseRepository.findAllByStudentIdAndStatus(studentId, StudentCourseStatus.PLANNED); + } + + public List getInProgressCourses(String studentId) { //수강 중인 과목 조회 + return studentCourseRepository.findAllByStudentIdAndStatus(studentId, StudentCourseStatus.IN_PROGRESS); + } + + + private double calculateTrackProgress(Track track, List completedCourses) { List trackCourses = trackCourseRepository.findAllByTrack(track); if (trackCourses.isEmpty()) return 0.0;