From 0a925e8e9175baf03626f0b217caf886efa33209 Mon Sep 17 00:00:00 2001 From: DHkimgit Date: Wed, 17 Dec 2025 21:20:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=98=EA=B0=95=EC=8B=A0=EC=B2=AD=20?= =?UTF-8?q?=EC=97=B0=EC=8A=B5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CourseRegistrationApi.java | 82 ++++++++++++++ .../CourseRegistrationController.java | 46 ++++++++ .../CourseRegistrationLectureResponse.java | 64 +++++++++++ .../dto/InnerLectureInfo.java | 18 +++ .../dto/LectureSearchCriteria.java | 28 +++++ .../PreCourseRegistrationLectureResponse.java | 84 ++++++++++++++ .../repository/CourseCustomRepository.java | 45 ++++++++ .../service/CourseRegistrationService.java | 41 +++++++ .../util/CourseClassTimeParser.java | 106 ++++++++++++++++++ .../global/config/SwaggerGroupConfig.java | 1 + 10 files changed, 515 insertions(+) create mode 100644 src/main/java/in/koreatech/koin/domain/course_registration/controller/CourseRegistrationApi.java create mode 100644 src/main/java/in/koreatech/koin/domain/course_registration/controller/CourseRegistrationController.java create mode 100644 src/main/java/in/koreatech/koin/domain/course_registration/dto/CourseRegistrationLectureResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/course_registration/dto/InnerLectureInfo.java create mode 100644 src/main/java/in/koreatech/koin/domain/course_registration/dto/LectureSearchCriteria.java create mode 100644 src/main/java/in/koreatech/koin/domain/course_registration/dto/PreCourseRegistrationLectureResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/course_registration/repository/CourseCustomRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/course_registration/service/CourseRegistrationService.java create mode 100644 src/main/java/in/koreatech/koin/domain/course_registration/util/CourseClassTimeParser.java diff --git a/src/main/java/in/koreatech/koin/domain/course_registration/controller/CourseRegistrationApi.java b/src/main/java/in/koreatech/koin/domain/course_registration/controller/CourseRegistrationApi.java new file mode 100644 index 000000000..1a4166671 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/course_registration/controller/CourseRegistrationApi.java @@ -0,0 +1,82 @@ +package in.koreatech.koin.domain.course_registration.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COUNCIL; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.course_registration.dto.CourseRegistrationLectureResponse; +import in.koreatech.koin.domain.course_registration.dto.PreCourseRegistrationLectureResponse; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Course Registration: 수강 신청 연습", description = "수강 신청 연습 정보를 관리한다") +public interface CourseRegistrationApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "예비 수강 신청 과목 조회", description = """ + ### 예비 수강 신청 과목 조회 (로그인 필요) + - **timetable_frame_id**: 시간표 프레임 ID + - **department**: 학과 (커스텀 수업인 경우 "-") + - **class_number**: 분반 (커스텀 수업인 경우 null) + - **lecture_info.lecture_code**: 과목 코드 (커스텀 수업인 경우 null) + - **grades**: 학점 (커스텀 수업인 경우 0) + - **class_time_raw**: 수업 교시 원본 데이터 (int 배열) + """) + @GetMapping("/course/registration/precourse") + ResponseEntity> getPreRegistrationLecture( + @RequestParam(value = "timetable_frame_id") Integer timetableFrameId, + @Auth(permit = {STUDENT, COUNCIL}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "전체 수강 신청 가능 과목 조회", description = """ + ### 수강 신청 가능 과목 조회 + - **name**: 과목 이름 + - **department**: 학과 + - HRD학과 + - 전기ㆍ전자ㆍ통신공학부 + - 산업경영학부 + - 교양학부 + - 기계공학부 + - 컴퓨터공학부 + - 메카트로닉스공학부 + - 에너지신소재화학공학부 + - 디자인ㆍ건축공학부 + - 미래융합학부 + - 고용서비스정책학과 + - **year**: 학년도 ex) 2024, 2025 ... + - **semester**: 학기 + - **1학기** + - **여름학기** + - **2학기** + - **겨울학기** + """) + @GetMapping("/course/registration/search") + ResponseEntity> searchLectures( + @RequestParam(required = false) String name, + @RequestParam(required = false) String department, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) String semester + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/course_registration/controller/CourseRegistrationController.java b/src/main/java/in/koreatech/koin/domain/course_registration/controller/CourseRegistrationController.java new file mode 100644 index 000000000..b2e2a643c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/course_registration/controller/CourseRegistrationController.java @@ -0,0 +1,46 @@ +package in.koreatech.koin.domain.course_registration.controller; + +import static in.koreatech.koin.domain.user.model.UserType.COUNCIL; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.course_registration.dto.CourseRegistrationLectureResponse; +import in.koreatech.koin.domain.course_registration.dto.PreCourseRegistrationLectureResponse; +import in.koreatech.koin.domain.course_registration.service.CourseRegistrationService; +import in.koreatech.koin.global.auth.Auth; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class CourseRegistrationController implements CourseRegistrationApi { + + private final CourseRegistrationService courseRegistrationService; + + @GetMapping("/course/registration/precourse") + public ResponseEntity> getPreRegistrationLecture( + @RequestParam(value = "timetable_frame_id") Integer timetableFrameId, + @Auth(permit = {STUDENT, COUNCIL}) Integer userId + ) { + List response = courseRegistrationService.getUserPreRegistrationCourses( + timetableFrameId, userId); + return ResponseEntity.ok(response); + } + + @GetMapping("/course/registration/search") + public ResponseEntity> searchLectures( + @RequestParam(required = false) String name, + @RequestParam(required = false) String department, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) String semester + ) { + List lectures = courseRegistrationService.searchCourseRegistrationLecture( + name, department, year, semester); + return ResponseEntity.ok(lectures); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/course_registration/dto/CourseRegistrationLectureResponse.java b/src/main/java/in/koreatech/koin/domain/course_registration/dto/CourseRegistrationLectureResponse.java new file mode 100644 index 000000000..a5c7de69f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/course_registration/dto/CourseRegistrationLectureResponse.java @@ -0,0 +1,64 @@ +package in.koreatech.koin.domain.course_registration.dto; + +import static in.koreatech.koin.domain.course_registration.util.CourseClassTimeParser.extractRawClassTime; +import static in.koreatech.koin.domain.course_registration.util.CourseClassTimeParser.parseClassTime; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record CourseRegistrationLectureResponse( + + @Schema(description = "학과", example = "기계공학부", requiredMode = REQUIRED) + String department, + + @Schema(description = "과목 정보", requiredMode = REQUIRED) + InnerLectureInfo lectureInfo, + + @Schema(description = "분반", example = "01", requiredMode = REQUIRED) + String classNumber, + + @Schema(description = "학점", example = "3", requiredMode = REQUIRED) + String grades, + + @Schema(description = "교수명", example = "홍길동", requiredMode = REQUIRED) + String professor, + + @Schema(description = "수강 정원", example = "38", requiredMode = REQUIRED) + String regularNumber, + + @Schema(description = "수업 교시", example = "월01A~03B,화01A~03B", requiredMode = REQUIRED) + String classTime, + + @Schema(description = "수업 교시 원본 데이터", example = "[0,1,2,3,4,5,100,101,102,103,104,105]", requiredMode = REQUIRED) + List classTimeRaw +) { + + public static CourseRegistrationLectureResponse from(Lecture lecture) { + List classTimeRaw = extractRawClassTime(lecture.getClassTime()); + + return new CourseRegistrationLectureResponse( + lecture.getDepartment(), + new InnerLectureInfo(lecture.getCode(), lecture.getName()), + lecture.getLectureClass(), + lecture.getGrades(), + lecture.getProfessor(), + lecture.getRegularNumber(), + parseClassTime(classTimeRaw), + classTimeRaw + ); + } + + public static List fromList(List lectures) { + return lectures.stream() + .map(CourseRegistrationLectureResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/course_registration/dto/InnerLectureInfo.java b/src/main/java/in/koreatech/koin/domain/course_registration/dto/InnerLectureInfo.java new file mode 100644 index 000000000..9f3752ba9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/course_registration/dto/InnerLectureInfo.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.course_registration.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record InnerLectureInfo( + @Schema(description = "과목코드", example = "MEB302", requiredMode = REQUIRED) + String lectureCode, + + @Schema(description = "과목명", example = "정역학", requiredMode = REQUIRED) + String lectureName +) { +} diff --git a/src/main/java/in/koreatech/koin/domain/course_registration/dto/LectureSearchCriteria.java b/src/main/java/in/koreatech/koin/domain/course_registration/dto/LectureSearchCriteria.java new file mode 100644 index 000000000..b1a8d0889 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/course_registration/dto/LectureSearchCriteria.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.domain.course_registration.dto; + +public record LectureSearchCriteria( + String name, + String department, + Integer year, + String semester +) { + + /** + * 년도와 학기를 조합하여 semester_date 형식으로 변환 + * 2025년 1학기 -> "20251" + * 2025년 겨울학기 -> "2025-겨울" + */ + public String getSemesterDate() { + if (year == null || semester == null) { + return null; + } + + return switch (semester) { + case "1학기" -> year + "1"; + case "여름학기" -> year + "-여름"; + case "2학기" -> year + "2"; + case "겨울학기" -> year + "-겨울"; + default -> null; + }; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/course_registration/dto/PreCourseRegistrationLectureResponse.java b/src/main/java/in/koreatech/koin/domain/course_registration/dto/PreCourseRegistrationLectureResponse.java new file mode 100644 index 000000000..d4ca400b3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/course_registration/dto/PreCourseRegistrationLectureResponse.java @@ -0,0 +1,84 @@ +package in.koreatech.koin.domain.course_registration.dto; + +import static in.koreatech.koin.domain.course_registration.util.CourseClassTimeParser.extractRawClassTime; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; +import java.util.Objects; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record PreCourseRegistrationLectureResponse ( + + @Schema(description = "학과", example = "컴퓨터공학부", requiredMode = REQUIRED) + String department, + + @Schema(description = "교과목명", example = "1", requiredMode = REQUIRED) + InnerLectureInfo lectureInfo, + + @Schema(description = "분반", example = "01", requiredMode = REQUIRED) + String classNumber, + + @Schema(description = "학점", example = "3", requiredMode = REQUIRED) + String grades, + + @Schema(description = "수업 교시 원본 데이터", example = "[0,1,2,3,4,5,100,101,102,103,104,105]", requiredMode = REQUIRED) + List classTimeRaw +) { + + public static PreCourseRegistrationLectureResponse from(TimetableLecture timetableLecture) { + Lecture lecture = timetableLecture.getLecture(); + + String lectureCode; + String lectureName; + String classNumber; + String grades; + List classTimeRaw; + + // 커스텀 강의 + if (lecture == null) { + lectureCode = null; + lectureName = timetableLecture.getClassTitle(); + classNumber = null; + grades = timetableLecture.getGrades(); + classTimeRaw = extractRawClassTime(timetableLecture.getClassTime()); + } else { // 일반 강의 + lectureCode = lecture.getCode(); + lectureName = lecture.getName(); + classNumber = lecture.getLectureClass(); + grades = lecture.getGrades(); + String classTime = timetableLecture.getClassTime() != null + ? timetableLecture.getClassTime() + : lecture.getClassTime(); + classTimeRaw = extractRawClassTime(classTime); + } + + return new PreCourseRegistrationLectureResponse( + getDepartment(timetableLecture), + new InnerLectureInfo(lectureCode, lectureName), + classNumber, + grades, + classTimeRaw + ); + } + + public static List fromList(List timetableLectures) { + return timetableLectures.stream() + .map(PreCourseRegistrationLectureResponse::from) + .toList(); + } + + private static String getDepartment(TimetableLecture timetableLecture) { + if (timetableLecture.getLecture() == null || + Objects.isNull(timetableLecture.getLecture().getDepartment())) { + return "-"; + } + return timetableLecture.getLecture().getDepartment(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/course_registration/repository/CourseCustomRepository.java b/src/main/java/in/koreatech/koin/domain/course_registration/repository/CourseCustomRepository.java new file mode 100644 index 000000000..f3fe536e8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/course_registration/repository/CourseCustomRepository.java @@ -0,0 +1,45 @@ +package in.koreatech.koin.domain.course_registration.repository; + +import static in.koreatech.koin.domain.timetable.model.QLecture.lecture; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.course_registration.dto.LectureSearchCriteria; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CourseCustomRepository { + + private final JPAQueryFactory queryFactory; + + public List searchLectures(LectureSearchCriteria condition) { + return queryFactory + .selectFrom(lecture) + .where( + nameContains(condition.name()), + departmentEq(condition.department()), + semesterDateEq(condition.getSemesterDate()) + ) + .orderBy(lecture.semester.desc(), lecture.code.asc()) + .fetch(); + } + + private BooleanExpression nameContains(String name) { + return name != null ? lecture.name.containsIgnoreCase(name) : null; + } + + private BooleanExpression departmentEq(String department) { + return department != null ? lecture.department.eq(department) : null; + } + + private BooleanExpression semesterDateEq(String semesterDate) { + return semesterDate != null ? lecture.semester.eq(semesterDate) : null; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/course_registration/service/CourseRegistrationService.java b/src/main/java/in/koreatech/koin/domain/course_registration/service/CourseRegistrationService.java new file mode 100644 index 000000000..5e8cee87b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/course_registration/service/CourseRegistrationService.java @@ -0,0 +1,41 @@ +package in.koreatech.koin.domain.course_registration.service; + +import static in.koreatech.koin.domain.timetableV2.validation.TimetableFrameValidate.validateUserOwnsFrame; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.course_registration.dto.CourseRegistrationLectureResponse; +import in.koreatech.koin.domain.course_registration.dto.LectureSearchCriteria; +import in.koreatech.koin.domain.course_registration.dto.PreCourseRegistrationLectureResponse; +import in.koreatech.koin.domain.course_registration.repository.CourseCustomRepository; +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV3.repository.TimetableFrameRepositoryV3; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CourseRegistrationService { + + private final TimetableFrameRepositoryV3 timetableFrameRepositoryV3; + private final CourseCustomRepository courseCustomRepository; + + public List getUserPreRegistrationCourses(Integer timetableFrameId, + Integer userId) { + TimetableFrame frame = timetableFrameRepositoryV3.getById(timetableFrameId); + validateUserOwnsFrame(frame.getUser().getId(), userId); + + return PreCourseRegistrationLectureResponse.fromList(frame.getTimetableLectures()); + } + + public List searchCourseRegistrationLecture(String name, String department, Integer year, String semester) { + LectureSearchCriteria criteria = new LectureSearchCriteria(name, department, year, semester); + List lectures = courseCustomRepository.searchLectures(criteria); + + return CourseRegistrationLectureResponse.fromList(lectures); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/course_registration/util/CourseClassTimeParser.java b/src/main/java/in/koreatech/koin/domain/course_registration/util/CourseClassTimeParser.java new file mode 100644 index 000000000..8b81e5c83 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/course_registration/util/CourseClassTimeParser.java @@ -0,0 +1,106 @@ +package in.koreatech.koin.domain.course_registration.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class CourseClassTimeParser { + + /** + * classTime 문자열 "[1, 2, 101, 102]" 에서 정수 배열 추출 + */ + public static List extractRawClassTime(String classTime) { + if (classTime == null || classTime.isEmpty()) { + return Collections.emptyList(); + } + + String cleaned = classTime.replaceAll("[\\[\\]\\s]", ""); + if (cleaned.isEmpty()) { + return Collections.emptyList(); + } + + try { + return Arrays.stream(cleaned.split(",")) + .map(Integer::parseInt) + .sorted() + .collect(Collectors.toList()); + } catch (NumberFormatException e) { + return Collections.emptyList(); + } + } + + /** + * classTime 문자열을 파싱하여 "월01A~03B,화01A~03B" 형식으로 변환 + * [1, 2, 101, 102] -> "월01A~01B,화01A-01B" + */ + public static String parseClassTime(List timeSlots) { + if (timeSlots == null || timeSlots.isEmpty()) { + return ""; + } + + Map> dayGroups = new LinkedHashMap<>(); + dayGroups.put("월", new ArrayList<>()); + dayGroups.put("화", new ArrayList<>()); + dayGroups.put("수", new ArrayList<>()); + dayGroups.put("목", new ArrayList<>()); + dayGroups.put("금", new ArrayList<>()); + + for (Integer slot : timeSlots) { + int day = slot / 100; + int period = slot % 100; + + String dayName = switch (day) { + case 0 -> "월"; + case 1 -> "화"; + case 2 -> "수"; + case 3 -> "목"; + case 4 -> "금"; + default -> null; + }; + + if (dayName != null && period <= 5) { + dayGroups.get(dayName).add(period); + } + } + + List result = new ArrayList<>(); + for (Map.Entry> entry : dayGroups.entrySet()) { + if (entry.getValue().isEmpty()) { + continue; + } + + String dayName = entry.getKey(); + List periods = entry.getValue(); + String timeRange = formatTimeRange(periods); + result.add(dayName + timeRange); + } + + return String.join(",", result); + } + + private static String formatTimeRange(List periods) { + if (periods.isEmpty()) { + return ""; + } + + Collections.sort(periods); + int start = periods.get(0); + int end = periods.get(periods.size() - 1); + + if (start == end) { + return convertToTimeCode(start); + } + + return convertToTimeCode(start) + "~" + convertToTimeCode(end); + } + + private static String convertToTimeCode(int period) { + int hour = (period / 2) + 1; + String ab = (period % 2 == 0) ? "A" : "B"; + return String.format("%02d%s", hour, ab); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java index da4bf6382..a43b96d20 100644 --- a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java @@ -70,6 +70,7 @@ public GroupedOpenApi userApi() { "in.koreatech.koin.domain.timetable", "in.koreatech.koin.domain.timetableV2", "in.koreatech.koin.domain.timetableV3", + "in.koreatech.koin.domain.course_registration", "in.koreatech.koin.domain.dept", "in.koreatech.koin.domain.graduation", });