Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public enum DomainType {
ABTEST("AB 테스트"),

LANDS("복덕방"),
LECTURES("강의"),
SEMESTER("학기"),
COOP_SHOPS("생협 매장"),

USERS("회원"),
Expand Down
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()));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package in.koreatech.koin.admin.semester.controller;

import static in.koreatech.koin.admin.history.enums.DomainType.COOP_SEMESTER;
import static in.koreatech.koin.admin.history.enums.DomainType.SEMESTER;
import static in.koreatech.koin.domain.user.model.UserType.ADMIN;
import static in.koreatech.koin.global.code.ApiResponseCode.*;

Expand All @@ -14,15 +15,38 @@
import in.koreatech.koin.admin.history.aop.AdminActivityLogging;
import in.koreatech.koin.admin.semester.dto.AdminSemesterCreateRequest;
import in.koreatech.koin.admin.semester.dto.AdminSemesterResponse;
import in.koreatech.koin.admin.semester.dto.AdminTimetableSemesterCreateRequest;
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) Semester : 학기", description = "관리자 권한으로 학기 정보를 관리한다")
public interface AdminSemesterApi {

@ApiResponseCodes({
OK,
INVALID_REQUEST_BODY,
UNAUTHORIZED_USER,
FORBIDDEN_ADMIN,
NOT_READABLE_HTTP_MESSAGE,
DUPLICATE_SEMESTER
})
@Operation(summary = "(ADMIN) 시간표 학기 생성", description = """
- `year`와 `term`을 사용해 시간표 학기를 생성합니다.
- `term`은 `FIRST`, `SECOND`, `SUMMER`, `WINTER` 중 하나입니다.
- 이미 같은 연도와 학기가 존재하면 생성하지 않습니다.
""")
@SecurityRequirement(name = "Jwt Authentication")
@AdminActivityLogging(domain = SEMESTER)
@PostMapping("/admin/semesters")
ResponseEntity<Void> createSemester(
@Valid @RequestBody AdminTimetableSemesterCreateRequest request,
@Auth(permit = {ADMIN}) Integer adminId
);

@ApiResponseCodes({
OK,
INVALID_SEMESTER_FORMAT,
Expand Down
Loading
Loading