-
Notifications
You must be signed in to change notification settings - Fork 2
feat: 어드민 강의 생성 API 구현 #2322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: 어드민 강의 생성 API 구현 #2322
Changes from all commits
c5b0927
8790411
e490ae9
2794448
fe765b6
cc2171c
137f3de
6b4e1e1
e0f42c4
071c4ee
839775e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Void> createLectures( | ||
| @RequestBody @Valid AdminLectureCreateRequest request, | ||
| @Auth(permit = {ADMIN}) Integer adminId | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Void> createLectures( | ||
| @RequestBody @Valid AdminLectureCreateRequest request, | ||
| @Auth(permit = {ADMIN}) Integer adminId | ||
| ) { | ||
| adminLectureService.createLectures(request); | ||
| return ResponseEntity.ok().build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<LectureRequest> lectures | ||
| ) { | ||
|
|
||
| public List<Lecture> 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(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Lecture, Integer> { | ||
|
|
||
| boolean existsBySemesterAndCodeAndLectureClass(String semester, String code, String lectureClass); | ||
|
|
||
| List<Lecture> saveAll(Iterable<Lecture> lectures); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Semester, Integer> { | ||
|
|
||
| Optional<Semester> 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() | ||
| )); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<LectureKey> 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())); | ||
|
Comment on lines
+35
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
fd -t f 'Lecture.java' src
rg -n -C 4 '`@Table`|`@UniqueConstraint`|uniqueConstraints|semester|lectureClass|lecture_class' src
rg -n -i -C 4 'create table.*lecture|alter table.*lecture|unique.*lecture|lecture_class' .Repository: BCSDLab/KOIN_API_V2 Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '--- AdminLectureService ---\n'
fd -t f 'AdminLectureService.java' src -x sh -c 'wc -l "$1"; sed -n "1,120p" "$1"' sh {}
printf '\n--- AdminLectureRepository definitions/usages ---\n'
fd -t f 'AdminLectureRepository.java|AdminLecture.*Repository.java' src -x sh -c 'echo "== $1 =="; cat -n "$1"' sh {}
printf '\n--- Lecture model entity ---\n'
fd -t f 'Lecture.java' src -x sh -c 'echo "== $1 =="; cat -n "$1"' sh {}
printf '\n--- targeted DDL/migrations references ---\n'
git ls-files | rg -i '(migration|flyway|liquibase|ddl|schema|lecture)' | head -200 | while read -r f; do
case "$f" in
*.java|*.sql|*.yml|*.yaml|*.properties|*.xml)
if rg -qi 'lectures|uniqueConstraints|semester|code|lecture_class|lectureclass|lecture_class' "$f"; then
echo "== $f =="
rg -n -C 3 -i 'lectures|uniqueConstraints| semester_date| code| lecture_class| lectureClass|lectureclass' "$f" | head -80
fi
;;
esac
doneRepository: BCSDLab/KOIN_API_V2 Length of output: 248 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- AdminLectureService ---'
fd -t f 'AdminLectureService.java' src -x sh -c 'wc -l "$1"; sed -n "1,140p" "$1"' sh {}
echo
echo '--- AdminLecture repository definitions/usages ---'
fd -t f 'AdminLecture.*Repository.java' src -x sh -c 'echo "==$1=="; cat -n "$1"' sh {} || true
echo
echo '--- lecture repository ---'
fd -t f 'LectureRepository.java' src -x sh -c 'echo "==$1=="; cat -n "$1"' sh {}
echo
echo '--- Lecture model entity ---'
fd -t f 'Lecture.java' src -x sh -c 'echo "==$1=="; cat -n "$1"' sh {}
echo
echo '--- targeted DDL/migrations references ---'
git ls-files | rg -i '(migration|flyway|liquibase|ddl|schema|lecture)' | head -200 | while read -r f; do
case "$f" in
*.java|*.sql|*.yml|*.yaml|*.properties|*.xml)
if rg -qi 'lectures|uniqueConstraints|semester_date| code| lecture_class|lectureClass|lectureclass' "$f"; then
echo "==$f=="
rg -n -C 3 -i 'lectures|uniqueConstraints|semester_date| code| lecture_class|lectureClass|lectureclass' "$f" | head -120
fi
;;
esac
doneRepository: BCSDLab/KOIN_API_V2 Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- files likely containing lectures DDL/migrations/config ---'
git ls-files | rg -i 'lectures|lecture|unique|ddl|schema|migration|flyway|liquibase|sql|yml|yaml|properties|pom|gradle' | head -300
echo
echo '--- exact unique/DUPLECTURE references ---'
rg -n -C 3 'DUPLICATE_LECTURE|uniqueConstraints|uniqueConstraints|constraint.*lectures|unique.*lectures|lectures\(|UNIQUE.*semester_date|UNIQUE|lecture_class|LECTURES' src main .github --glob '!build/**' --glob '!dist/**' || true
echo
echo '--- exception mapper around constraint failures ---'
fd -t f 'GlobalExceptionHandler.java|ExceptionHandler.java|*Exception*Handler.java|ApiResponseCode.java|ExceptionMapper.java|DatabaseAdapterResolver.java' src -x sh -c 'echo "==$1=="; sed -n "1,220p" "$1"' sh {} | rg -n -C 4 'DUPLICATE_LECTURE|DataIntegrity|ConstraintViolation|Duplicate|ex\.|constraint|unique|lectures' || trueRepository: BCSDLab/KOIN_API_V2 Length of output: 50375 Add a database guarantee for lecture identity.
🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reject blank values for required lecture fields.
@NotNullaccepts""and whitespace-only values.@Size(max = ...)does not reject them. The API can save blank values forregularNumber,target,isEnglish, andisElearningalthough these fields are required.Use
@NotBlankfor required text fields. Use an allowed-value constraint forisEnglishandisElearningif the accepted values are fixed.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents