diff --git a/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java b/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java index 25a43a3f0f..f6d11c71a8 100644 --- a/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java +++ b/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java @@ -26,6 +26,7 @@ public enum DomainType { ABTEST("AB 테스트"), LANDS("복덕방"), + LECTURES("강의"), COOP_SHOPS("생협 매장"), USERS("회원"), diff --git a/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureApi.java b/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureApi.java new file mode 100644 index 0000000000..123bfa42f6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureApi.java @@ -0,0 +1,79 @@ +package in.koreatech.koin.admin.lecture.controller; + +import static in.koreatech.koin.admin.history.enums.DomainType.LECTURES; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static in.koreatech.koin.global.code.ApiResponseCode.DUPLICATE_LECTURE; +import static in.koreatech.koin.global.code.ApiResponseCode.FORBIDDEN_ADMIN; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_SEMESTER; +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_READABLE_HTTP_MESSAGE; +import static in.koreatech.koin.global.code.ApiResponseCode.OK; +import static in.koreatech.koin.global.code.ApiResponseCode.UNAUTHORIZED_USER; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import in.koreatech.koin.admin.history.aop.AdminActivityLogging; +import in.koreatech.koin.admin.lecture.dto.AdminLectureCreateRequest; +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.code.ApiResponseCodes; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Admin) Lecture: 강의", description = "관리자 권한으로 강의를 관리한다") +public interface AdminLectureApi { + + @ApiResponseCodes({ + OK, + INVALID_REQUEST_BODY, + UNAUTHORIZED_USER, + FORBIDDEN_ADMIN, + NOT_READABLE_HTTP_MESSAGE, + NOT_FOUND_SEMESTER, + DUPLICATE_LECTURE + }) + @Operation( + summary = "강의를 일괄 생성한다", + description = """ + ## 강의 일괄 생성 + 입력한 연도와 학기에 해당하는 강의들을 한 번에 생성합니다. + 요청한 학기가 존재하지 않거나 중복 강의가 포함된 경우 강의를 생성하지 않습니다. + 중복 여부는 학기, 과목 코드, 분반의 조합을 기준으로 판단합니다. + + ## 요청 Body 필드 설명 + - `year`: 강의를 등록할 연도 (필수, 양수) + - `term`: 강의를 등록할 학기 (필수) + - `FIRST`: 1학기 + - `SECOND`: 2학기 + - `SUMMER`: 여름학기 + - `WINTER`: 겨울학기 + - `lectures`: 생성할 강의 정보 리스트 (필수, 빈 리스트 불가) + - `code`: 과목 코드 (필수, 최대 10자) + - `name`: 과목 이름 (필수, 최대 50자) + - `grades`: 대상 학년 (필수, 최대 2자) + - `lecture_class`: 분반 (필수, 최대 3자) + - `regular_number`: 수강 인원 (필수, 최대 4자) + - `department`: 학부 (필수, 최대 30자) + - `target`: 수강 대상 (필수, 최대 200자) + - `professor`: 교수명 (선택, 최대 30자) + - `is_english`: 영어 강의 여부 (필수, 최대 2자) + - `design_score`: 설계 학점 (필수, 최대 2자) + - `is_elearning`: 이러닝 여부 (필수, 최대 2자) + - `class_time`: 강의 시간 코드 리스트 (필수, 최대 50개, 각 값은 0~999) + + ## 처리 결과 + - 모든 강의가 유효한 경우 일괄 생성하고 `200 OK`를 반환합니다. + - 요청 리스트 내부 또는 기존 강의와 중복되는 항목이 있으면 `DUPLICATE_LECTURE`를 반환합니다. + """ + ) + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/lectures") + @AdminActivityLogging(domain = LECTURES) + ResponseEntity createLectures( + @RequestBody @Valid AdminLectureCreateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureController.java b/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureController.java new file mode 100644 index 0000000000..99bafb5779 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureController.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.admin.lecture.controller; + +import static in.koreatech.koin.admin.history.enums.DomainType.LECTURES; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + +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 in.koreatech.koin.admin.history.aop.AdminActivityLogging; +import in.koreatech.koin.admin.lecture.dto.AdminLectureCreateRequest; +import in.koreatech.koin.admin.lecture.service.AdminLectureService; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AdminLectureController implements AdminLectureApi { + + private final AdminLectureService adminLectureService; + + @PostMapping("/admin/lectures") + @AdminActivityLogging(domain = LECTURES) + public ResponseEntity createLectures( + @RequestBody @Valid AdminLectureCreateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminLectureService.createLectures(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/dto/AdminLectureCreateRequest.java b/src/main/java/in/koreatech/koin/admin/lecture/dto/AdminLectureCreateRequest.java new file mode 100644 index 0000000000..137c1fb391 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/dto/AdminLectureCreateRequest.java @@ -0,0 +1,126 @@ +package in.koreatech.koin.admin.lecture.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetableV3.model.Term; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminLectureCreateRequest( + @Schema(description = "연도", example = "2026", requiredMode = REQUIRED) + @NotNull(message = "연도는 필수입니다.") + @Positive(message = "연도는 양수여야 합니다.") + Integer year, + + @Schema(description = "학기", example = "FIRST", requiredMode = REQUIRED) + @NotNull(message = "학기는 필수입니다.") + Term term, + + @Schema(description = "강의 정보 리스트", requiredMode = REQUIRED) + @Valid + @NotEmpty(message = "강의 정보 리스트는 비어 있을 수 없습니다.") + List lectures +) { + + public List toEntities(String semester) { + return lectures.stream() + .map(lecture -> lecture.toEntity(semester)) + .toList(); + } + + @JsonNaming(SnakeCaseStrategy.class) + public record LectureRequest( + @Schema(description = "과목 코드", example = "ARB244", requiredMode = REQUIRED) + @NotBlank(message = "과목 코드는 필수입니다.") + @Size(max = 10, message = "과목 코드는 10자 이하여야 합니다.") + String code, + + @Schema(description = "과목 이름", example = "건축구조의 이해 및 실습", requiredMode = REQUIRED) + @NotBlank(message = "과목 이름은 필수입니다.") + @Size(max = 50, message = "과목 이름은 50자 이하여야 합니다.") + String name, + + @Schema(description = "대상 학년", example = "3", requiredMode = REQUIRED) + @NotBlank(message = "대상 학년은 필수입니다.") + @Size(max = 2, message = "대상 학년은 2자 이하여야 합니다.") + String grades, + + @Schema(description = "분반", example = "01", requiredMode = REQUIRED) + @NotBlank(message = "분반은 필수입니다.") + @Size(max = 3, message = "분반은 3자 이하여야 합니다.") + String lectureClass, + + @Schema(description = "수강 인원", example = "25", requiredMode = REQUIRED) + @NotNull(message = "수강 인원은 필수입니다.") + @Size(max = 4, message = "수강 인원은 4자 이하여야 합니다.") + String regularNumber, + + @Schema(description = "학부", example = "디자인ㆍ건축공학부", requiredMode = REQUIRED) + @NotBlank(message = "학부는 필수입니다.") + @Size(max = 30, message = "학부는 30자 이하여야 합니다.") + String department, + + @Schema(description = "대상", example = "디자 1 건축", requiredMode = REQUIRED) + @NotNull(message = "대상은 필수입니다.") + @Size(max = 200, message = "대상은 200자 이하여야 합니다.") + String target, + + @Schema(description = "교수", example = "황현식", requiredMode = NOT_REQUIRED) + @Size(max = 30, message = "교수명은 30자 이하여야 합니다.") + String professor, + + @Schema(description = "영어 강의 여부", example = "N", requiredMode = REQUIRED) + @NotNull(message = "영어 강의 여부는 필수입니다.") + @Size(max = 2, message = "영어 강의 여부는 2자 이하여야 합니다.") + String isEnglish, + + @Schema(description = "설계 학점", example = "0", requiredMode = REQUIRED) + @NotBlank(message = "설계 학점은 필수입니다.") + @Size(max = 2, message = "설계 학점은 2자 이하여야 합니다.") + String designScore, + + @Schema(description = "이러닝 여부", example = "N", requiredMode = REQUIRED) + @NotNull(message = "이러닝 여부는 필수입니다.") + @Size(max = 2, message = "이러닝 여부는 2자 이하여야 합니다.") + String isElearning, + + @Schema(description = "강의 시간", example = "[200, 201, 202, 203]", requiredMode = REQUIRED) + @NotNull(message = "강의 시간은 필수입니다.") + @Size(max = 50, message = "강의 시간은 최대 50개까지 입력할 수 있습니다.") + List<@NotNull @PositiveOrZero @Max(999) Integer> classTime + ) { + + public Lecture toEntity(String semester) { + return Lecture.builder() + .semester(semester) + .code(code) + .name(name) + .grades(grades) + .lectureClass(lectureClass) + .regularNumber(regularNumber) + .department(department) + .target(target) + .professor(professor) + .isEnglish(isEnglish) + .designScore(designScore) + .isElearning(isElearning) + .classTime(classTime.toString()) + .build(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/model/LectureKey.java b/src/main/java/in/koreatech/koin/admin/lecture/model/LectureKey.java new file mode 100644 index 0000000000..ccfd165a77 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/model/LectureKey.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.admin.lecture.model; + +public record LectureKey( + String code, + String lectureClass +) { + + public static LectureKey of(String code, String lectureClass) { + return new LectureKey(code, lectureClass); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminLectureRepository.java b/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminLectureRepository.java new file mode 100644 index 0000000000..617bb68c6b --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminLectureRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.lecture.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.model.Lecture; + +public interface AdminLectureRepository extends Repository { + + boolean existsBySemesterAndCodeAndLectureClass(String semester, String code, String lectureClass); + + List saveAll(Iterable lectures); +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminSemesterRepository.java b/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminSemesterRepository.java new file mode 100644 index 0000000000..b3ffe6d033 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminSemesterRepository.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.admin.lecture.repository; + +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_SEMESTER; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV3.model.Term; +import in.koreatech.koin.global.exception.CustomException; + +public interface AdminSemesterRepository extends Repository { + + Optional findByYearAndTerm(Integer year, Term term); + + default Semester getByYearAndTerm(Integer year, Term term) { + return findByYearAndTerm(year, term) + .orElseThrow(() -> CustomException.of( + NOT_FOUND_SEMESTER, + "year: " + year + ", term: " + term.getDescription() + )); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/service/AdminLectureService.java b/src/main/java/in/koreatech/koin/admin/lecture/service/AdminLectureService.java new file mode 100644 index 0000000000..a583e0acaa --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/service/AdminLectureService.java @@ -0,0 +1,48 @@ +package in.koreatech.koin.admin.lecture.service; + +import static in.koreatech.koin.global.code.ApiResponseCode.DUPLICATE_LECTURE; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.lecture.dto.AdminLectureCreateRequest; +import in.koreatech.koin.admin.lecture.dto.AdminLectureCreateRequest.LectureRequest; +import in.koreatech.koin.admin.lecture.model.LectureKey; +import in.koreatech.koin.admin.lecture.repository.AdminLectureRepository; +import in.koreatech.koin.admin.lecture.repository.AdminSemesterRepository; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminLectureService { + + private final AdminLectureRepository adminLectureRepository; + private final AdminSemesterRepository adminSemesterRepository; + + @Transactional + public void createLectures(AdminLectureCreateRequest request) { + Semester semester = adminSemesterRepository.getByYearAndTerm(request.year(), request.term()); + + Set lectureKeys = new HashSet<>(); + for (LectureRequest lecture : request.lectures()) { + LectureKey lectureKey = LectureKey.of(lecture.code(), lecture.lectureClass()); + if (!lectureKeys.add(lectureKey) || adminLectureRepository.existsBySemesterAndCodeAndLectureClass( + semester.getSemester(), lecture.code(), lecture.lectureClass() + )) { + throw CustomException.of( + DUPLICATE_LECTURE, + "semester: " + semester.getSemester() + ", code: " + lecture.code() + + ", lectureClass: " + lecture.lectureClass() + ); + } + } + + adminLectureRepository.saveAll(request.toEntities(semester.getSemester())); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV3/exception/InvalidTermFormatException.java b/src/main/java/in/koreatech/koin/domain/timetableV3/exception/InvalidTermFormatException.java deleted file mode 100644 index 70bed37615..0000000000 --- a/src/main/java/in/koreatech/koin/domain/timetableV3/exception/InvalidTermFormatException.java +++ /dev/null @@ -1,20 +0,0 @@ -package in.koreatech.koin.domain.timetableV3.exception; - -import in.koreatech.koin.global.exception.custom.KoinIllegalArgumentException; - -public class InvalidTermFormatException extends KoinIllegalArgumentException { - - private static final String DEFAULT_MESSAGE = "term 양식이 잘못됐습니다."; - - public InvalidTermFormatException(String message) { - super(message); - } - - public InvalidTermFormatException(String message, String detail) { - super(message, detail); - } - - public static InvalidTermFormatException withDetail(String detail) { - return new InvalidTermFormatException(DEFAULT_MESSAGE, detail); - } -} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV3/model/Term.java b/src/main/java/in/koreatech/koin/domain/timetableV3/model/Term.java index 08ab181e80..c39b1f1e58 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV3/model/Term.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV3/model/Term.java @@ -1,6 +1,8 @@ package in.koreatech.koin.domain.timetableV3.model; -import in.koreatech.koin.domain.timetableV3.exception.InvalidTermFormatException; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_SEMESTER_FORMAT; + +import in.koreatech.koin.global.exception.CustomException; import lombok.Getter; @Getter @@ -24,6 +26,6 @@ public static Term fromDescription(String description) { return term; } } - throw new InvalidTermFormatException("term : " + description); + throw CustomException.of(INVALID_SEMESTER_FORMAT, "term: " + description); } } diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index 15f486b981..c0da61a7c3 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -140,6 +140,7 @@ public enum ApiResponseCode { NOT_FOUND_PAYMENT(HttpStatus.NOT_FOUND, "결제 정보가 존재하지 않습니다."), NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, "주문 정보가 존재하지 않습니다."), NOT_FOUND_SHOP(HttpStatus.NOT_FOUND, "상점이 존재하지 않습니다."), + NOT_FOUND_SEMESTER(HttpStatus.NOT_FOUND, "해당 학기가 존재하지 않습니다."), NOT_FOUND_COOP_SEMESTER(HttpStatus.NOT_FOUND, "해당 학기가 존재하지 않습니다."), NOT_FOUND_SHOP_ORDER_SERVICE_REQUEST(HttpStatus.NOT_FOUND, "상점 서비스 전환 요청이 존재하지 않습니다."), NOT_FOUND_CHAT_PARTNER(HttpStatus.NOT_FOUND, "채팅 상대방이 존재하지 않습니다."), @@ -153,6 +154,7 @@ public enum ApiResponseCode { DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."), DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."), DUPLICATE_PHONE_NUMBER(HttpStatus.CONFLICT, "이미 존재하는 전화번호입니다."), + DUPLICATE_LECTURE(HttpStatus.CONFLICT, "이미 존재하는 강의입니다."), REQUEST_TOO_FAST(HttpStatus.CONFLICT, "요청이 너무 빠릅니다. 다시 요청해주세요."), OPTIMISTIC_LOCKING_FAILURE(HttpStatus.CONFLICT, "이미 처리된 요청입니다."), DUPLICATE_CLUB_RECRUITMENT(HttpStatus.CONFLICT, "동아리 공고가 이미 존재합니다."),