feat : 수강신청 도메인 구성
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package com.study.course_registration.config;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class ClockConfig {
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.study.course_registration.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
@Bean
|
||||
OpenAPI courseRegistrationOpenApi() {
|
||||
return new OpenAPI().info(new Info()
|
||||
.title("Course Registration API")
|
||||
.version("v1")
|
||||
.description("수강신청, 취소, 신청 목록 및 개설 강의 조회 API"));
|
||||
}
|
||||
}
|
||||
+48
-16
@@ -1,38 +1,70 @@
|
||||
package com.study.course_registration.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
|
||||
import com.study.course_registration.dto.registration.CourseRegistrationResponse;
|
||||
import com.study.course_registration.dto.registration.RegistrationListResponse;
|
||||
import com.study.course_registration.service.CourseRegistrationService;
|
||||
|
||||
import com.study.course_registration.dto.RequestDto;
|
||||
import com.study.course_registration.dto.ResponseDto;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@RestController
|
||||
@RequestMapping ("/api/v1")
|
||||
@Validated
|
||||
@RequestMapping("/api/v1/users/{userId}/registrations")
|
||||
@Tag(name = "Registrations", description = "수강신청/조회/취소 API")
|
||||
public class CourseRegistrationController {
|
||||
|
||||
private final CourseRegistrationService courseRegistrationService;
|
||||
|
||||
|
||||
public CourseRegistrationController(CourseRegistrationService courseRegistrationService) {
|
||||
this.courseRegistrationService = courseRegistrationService;
|
||||
}
|
||||
|
||||
@PostMapping("/course-register")
|
||||
@Operation(summary = "Register a course",
|
||||
description = "Registers a new course with the provided details.")
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@Operation(summary = "수강신청")
|
||||
@ApiResponses({
|
||||
@ApiResponse (responseCode = "200", description = "Course registered successfully"),
|
||||
@ApiResponse (responseCode = "400", description = "Invalid request data")
|
||||
@ApiResponse(responseCode = "201", description = "신청 성공"),
|
||||
@ApiResponse(responseCode = "403", description = "학년 또는 역할 제한", content = @Content),
|
||||
@ApiResponse(responseCode = "404", description = "사용자 또는 강의 없음", content = @Content),
|
||||
@ApiResponse(responseCode = "409", description = "기간/중복/시간표/학점/정원 충돌", content = @Content),
|
||||
@ApiResponse(responseCode = "503", description = "락 대기 타임아웃", content = @Content)
|
||||
})
|
||||
public ResponseDto registerCourse(@Validated RequestDto requestDto) {
|
||||
return courseRegistrationService.registerCourse(requestDto);
|
||||
public CourseRegistrationResponse register(
|
||||
@PathVariable String userId,
|
||||
@Valid @RequestBody CourseRegistrationRequest request) {
|
||||
return courseRegistrationService.register(userId, request);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "내 수강신청 목록 조회")
|
||||
public RegistrationListResponse getRegistrations(
|
||||
@PathVariable String userId,
|
||||
@RequestParam(required = false) String semesterId) {
|
||||
return courseRegistrationService.getRegistrations(userId, semesterId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{registrationId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@Operation(summary = "수강신청 취소")
|
||||
public void cancel(
|
||||
@PathVariable String userId,
|
||||
@PathVariable String registrationId) {
|
||||
courseRegistrationService.cancel(userId, registrationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.study.course_registration.controller;
|
||||
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.study.course_registration.dto.lesson.LessonDetailResponse;
|
||||
import com.study.course_registration.dto.lesson.LessonListResponse;
|
||||
import com.study.course_registration.service.LessonQueryService;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/v1/lessons")
|
||||
@Tag(name = "Lessons", description = "개설 강의 조회 API")
|
||||
public class LessonController {
|
||||
private final LessonQueryService lessonQueryService;
|
||||
|
||||
public LessonController(LessonQueryService lessonQueryService) {
|
||||
this.lessonQueryService = lessonQueryService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "개설 강의 목록 조회")
|
||||
public LessonListResponse getLessons(
|
||||
@RequestParam(required = false) String semesterId,
|
||||
@RequestParam(required = false) String subjectId,
|
||||
@RequestParam(defaultValue = "0") @Min(0) int page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
|
||||
return lessonQueryService.getLessons(semesterId, subjectId, page, size);
|
||||
}
|
||||
|
||||
@GetMapping("/{lessonId}")
|
||||
@Operation(summary = "개설 강의 상세 조회")
|
||||
public LessonDetailResponse getLesson(@PathVariable String lessonId) {
|
||||
return lessonQueryService.getLesson(lessonId);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.study.course_registration.dto;
|
||||
|
||||
public record RequestDto(
|
||||
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.study.course_registration.dto;
|
||||
|
||||
public record ResponseDto(
|
||||
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.study.course_registration.dto;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalTime;
|
||||
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
|
||||
public record ScheduleResponse(
|
||||
DayOfWeek dayOfWeek,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime) {
|
||||
public static ScheduleResponse from(LessonSchedule schedule) {
|
||||
return new ScheduleResponse(schedule.getDayOfWeek(), schedule.getStartTime(), schedule.getEndTime());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.study.course_registration.dto.lesson;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import com.study.course_registration.dto.ScheduleResponse;
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
|
||||
public record LessonDetailResponse(
|
||||
String lessonId,
|
||||
String lessonName,
|
||||
String subjectCode,
|
||||
String subjectName,
|
||||
String subjectDescription,
|
||||
Integer credit,
|
||||
String professorName,
|
||||
String semesterName,
|
||||
Integer capacity,
|
||||
Long enrolledCount,
|
||||
Long minGrade,
|
||||
UserRole allowedRole,
|
||||
Instant registrationStartAt,
|
||||
Instant registrationEndAt,
|
||||
List<ScheduleResponse> schedules) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.study.course_registration.dto.lesson;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.study.course_registration.dto.ScheduleResponse;
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
|
||||
public record LessonItemResponse(
|
||||
String lessonId,
|
||||
String lessonName,
|
||||
String subjectCode,
|
||||
String subjectName,
|
||||
Integer credit,
|
||||
String professorName,
|
||||
Integer capacity,
|
||||
Long enrolledCount,
|
||||
Long minGrade,
|
||||
UserRole allowedRole,
|
||||
List<ScheduleResponse> schedules) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.study.course_registration.dto.lesson;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record LessonListResponse(
|
||||
Integer page,
|
||||
Integer size,
|
||||
Long totalElements,
|
||||
List<LessonItemResponse> lessons) {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.study.course_registration.dto.registration;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CourseRegistrationRequest(
|
||||
@NotBlank(message = "lessonId는 필수입니다.") String lessonId) {
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.study.course_registration.dto.registration;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record CourseRegistrationResponse(
|
||||
String registrationId,
|
||||
String userId,
|
||||
String lessonId,
|
||||
String lessonName,
|
||||
String subjectCode,
|
||||
Integer credit,
|
||||
Instant registeredAt,
|
||||
Integer totalCredits) {
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.study.course_registration.dto.registration;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import com.study.course_registration.dto.ScheduleResponse;
|
||||
|
||||
public record RegistrationItemResponse(
|
||||
String registrationId,
|
||||
String lessonId,
|
||||
String lessonName,
|
||||
String subjectCode,
|
||||
String subjectName,
|
||||
Integer credit,
|
||||
String professorName,
|
||||
List<ScheduleResponse> schedules,
|
||||
Instant registeredAt) {
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.study.course_registration.dto.registration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record RegistrationListResponse(
|
||||
String userId,
|
||||
String semesterId,
|
||||
String semesterName,
|
||||
Integer totalCredits,
|
||||
Integer maxCredits,
|
||||
List<RegistrationItemResponse> registrations) {
|
||||
}
|
||||
@@ -5,11 +5,23 @@ import java.time.Instant;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.EntityListeners;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import lombok.Getter;
|
||||
|
||||
@MappedSuperclass
|
||||
@EntityListeners (AuditingEntityListener.class)
|
||||
public class BaseCreateEntity {
|
||||
@CreatedDate
|
||||
@Getter
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public abstract class BaseCreateEntity extends BaseEntity {
|
||||
@CreatedDate
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected BaseCreateEntity() {
|
||||
}
|
||||
|
||||
protected BaseCreateEntity(String id) {
|
||||
super(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
package com.study.course_registration.entity;
|
||||
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@MappedSuperclass
|
||||
public class BaseEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@MappedSuperclass
|
||||
public abstract class BaseEntity {
|
||||
@Id
|
||||
@Column(length = 36, nullable = false, updatable = false)
|
||||
private String id;
|
||||
|
||||
protected BaseEntity() {
|
||||
}
|
||||
|
||||
protected BaseEntity(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
protected void assignId() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,28 @@ import java.time.Instant;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.EntityListeners;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import lombok.Getter;
|
||||
|
||||
@MappedSuperclass
|
||||
@EntityListeners (AuditingEntityListener.class)
|
||||
public class BaseTimeZoneEntity extends BaseEntity {
|
||||
@CreatedDate
|
||||
@Getter
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public abstract class BaseTimeZoneEntity extends BaseEntity {
|
||||
@CreatedDate
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@LastModifiedDate
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
protected BaseTimeZoneEntity() {
|
||||
}
|
||||
|
||||
protected BaseTimeZoneEntity(String id) {
|
||||
super(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,74 @@
|
||||
package com.study.course_registration.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.ForeignKey;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Table(name = "lessons")
|
||||
public class Lesson extends BaseTimeZoneEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "subject_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_subject"))
|
||||
private Subject subject;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "professor_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_professor"))
|
||||
private Professor professor;
|
||||
|
||||
private Instant startTime;
|
||||
|
||||
private Instant endTime;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@Column (name = "semester_id", nullable = false)
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "semester_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_semester"))
|
||||
private Semester semester;
|
||||
|
||||
@Column (name = "capacity", nullable = false)
|
||||
@Column(nullable = false)
|
||||
private Integer capacity;
|
||||
|
||||
@Column (name = "min_grade", nullable = false)
|
||||
@Column(name = "min_grade")
|
||||
private Long minGrade;
|
||||
|
||||
@Column (name = "allowed_role", nullable = false)
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "allowed_role")
|
||||
private UserRole allowedRole;
|
||||
|
||||
public Lesson(String name, Subject subject, Professor professor, Instant startTime, Instant endTime, Semester semester, Integer capacity, Long minGrade, UserRole allowedRole) {
|
||||
public Lesson(String name, Subject subject, Professor professor, Semester semester,
|
||||
Integer capacity, Long minGrade, UserRole allowedRole) {
|
||||
this(null, name, subject, professor, semester, capacity, minGrade, allowedRole);
|
||||
}
|
||||
|
||||
public Lesson(String id, String name, Subject subject, Professor professor, Semester semester,
|
||||
Integer capacity, Long minGrade, UserRole allowedRole) {
|
||||
super(id);
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("name must not be blank");
|
||||
}
|
||||
if (capacity == null || capacity < 1) {
|
||||
throw new IllegalArgumentException("capacity must be at least 1");
|
||||
}
|
||||
if (minGrade != null && minGrade < 1) {
|
||||
throw new IllegalArgumentException("minGrade must be at least 1 when present");
|
||||
}
|
||||
this.name = name;
|
||||
this.subject = subject;
|
||||
this.professor = professor;
|
||||
this.startTime = startTime;
|
||||
this.endTime = endTime;
|
||||
this.semester = semester;
|
||||
this.subject = Objects.requireNonNull(subject, "subject");
|
||||
this.professor = Objects.requireNonNull(professor, "professor");
|
||||
this.semester = Objects.requireNonNull(semester, "semester");
|
||||
this.capacity = capacity;
|
||||
this.minGrade = minGrade;
|
||||
this.allowedRole = allowedRole;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,51 @@ package com.study.course_registration.entity;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalTime;
|
||||
import java.util.Objects;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.ForeignKey;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Entity
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Table(name = "lesson_schedules")
|
||||
public class LessonSchedule extends BaseTimeZoneEntity {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@Column()
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_schedule_lesson"))
|
||||
private Lesson lesson;
|
||||
|
||||
@Column()
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
private DayOfWeek dayOfWeek;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalTime startTime;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalTime endTime;
|
||||
|
||||
public LessonSchedule(Lesson lesson, DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
|
||||
this(null, lesson, dayOfWeek, startTime, endTime);
|
||||
}
|
||||
|
||||
public LessonSchedule(String id, Lesson lesson, DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
|
||||
super(id);
|
||||
this.lesson = Objects.requireNonNull(lesson, "lesson");
|
||||
this.dayOfWeek = Objects.requireNonNull(dayOfWeek, "dayOfWeek");
|
||||
this.startTime = Objects.requireNonNull(startTime, "startTime");
|
||||
this.endTime = Objects.requireNonNull(endTime, "endTime");
|
||||
if (!startTime.isBefore(endTime)) {
|
||||
throw new IllegalArgumentException("startTime must be before endTime");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
package com.study.course_registration.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Table(name = "professors")
|
||||
public class Professor extends BaseTimeZoneEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
public Professor(String name) {
|
||||
this(null, name);
|
||||
}
|
||||
|
||||
public Professor(String id, String name) {
|
||||
super(id);
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("name must not be blank");
|
||||
}
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,67 @@ package com.study.course_registration.entity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Entity
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "semesters", uniqueConstraints = @UniqueConstraint(name = "uk_semester_name", columnNames = "name"))
|
||||
public class Semester extends BaseTimeZoneEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate startDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate endDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant registrationStartAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant registrationEndAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer maxCredits;
|
||||
|
||||
public Semester(String name) {
|
||||
this.name = name;
|
||||
public Semester(String name, LocalDate startDate, LocalDate endDate,
|
||||
Instant registrationStartAt, Instant registrationEndAt, Integer maxCredits) {
|
||||
this(null, name, startDate, endDate, registrationStartAt, registrationEndAt, maxCredits);
|
||||
}
|
||||
|
||||
public Semester(String id, String name, LocalDate startDate, LocalDate endDate,
|
||||
Instant registrationStartAt, Instant registrationEndAt, Integer maxCredits) {
|
||||
super(id);
|
||||
this.name = requireText(name, "name");
|
||||
this.startDate = Objects.requireNonNull(startDate, "startDate");
|
||||
this.endDate = Objects.requireNonNull(endDate, "endDate");
|
||||
this.registrationStartAt = Objects.requireNonNull(registrationStartAt, "registrationStartAt");
|
||||
this.registrationEndAt = Objects.requireNonNull(registrationEndAt, "registrationEndAt");
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new IllegalArgumentException("endDate must be on or after startDate");
|
||||
}
|
||||
if (!registrationStartAt.isBefore(registrationEndAt)) {
|
||||
throw new IllegalArgumentException("registrationStartAt must be before registrationEndAt");
|
||||
}
|
||||
if (maxCredits == null || maxCredits < 1) {
|
||||
throw new IllegalArgumentException("maxCredits must be at least 1");
|
||||
}
|
||||
this.maxCredits = maxCredits;
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
package com.study.course_registration.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Table(name = "subjects")
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Table(name = "subjects", uniqueConstraints = @UniqueConstraint(name = "uk_subject_code", columnNames = "code"))
|
||||
public class Subject extends BaseTimeZoneEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 1000)
|
||||
private String description;
|
||||
|
||||
public Subject(String name, String code, String description) {
|
||||
this.name = name;
|
||||
this.code = code;
|
||||
this.description = description;
|
||||
@Column(nullable = false)
|
||||
private Integer credit;
|
||||
|
||||
public Subject(String name, String code, String description, Integer credit) {
|
||||
this(null, name, code, description, credit);
|
||||
}
|
||||
|
||||
public Subject(String id, String name, String code, String description, Integer credit) {
|
||||
super(id);
|
||||
if (credit == null || credit < 1) {
|
||||
throw new IllegalArgumentException("credit must be at least 1");
|
||||
}
|
||||
this.name = requireText(name, "name");
|
||||
this.code = requireText(code, "code");
|
||||
this.description = description == null ? "" : description;
|
||||
this.credit = credit;
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
package com.study.course_registration.entity;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "users")
|
||||
public class User extends BaseTimeZoneEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long grade;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private UserRole role;
|
||||
|
||||
public User(String name, Long grade, UserRole role) {
|
||||
this(null, name, grade, role);
|
||||
}
|
||||
|
||||
public User(String id, String name, Long grade, UserRole role) {
|
||||
super(id);
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("name must not be blank");
|
||||
}
|
||||
if (grade == null || grade < 1) {
|
||||
throw new IllegalArgumentException("grade must be at least 1");
|
||||
}
|
||||
this.name = name;
|
||||
this.grade = grade;
|
||||
this.role = role;
|
||||
this.role = Objects.requireNonNull(role, "role");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
package com.study.course_registration.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.ForeignKey;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Table(name = "user_lessons")
|
||||
public class UserLesson extends BaseCreateEntity {
|
||||
|
||||
@EmbeddedId
|
||||
private UserLessonId id;
|
||||
|
||||
@MapsId("userId")
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn (
|
||||
name = "user_id",
|
||||
foreignKey = @ForeignKey(name = "fk_user_lesson_user_id")
|
||||
)
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_user"))
|
||||
private User user;
|
||||
|
||||
@MapsId("lessonId")
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn (
|
||||
name = "lesson_id",
|
||||
foreignKey = @ForeignKey(name = "fk_user_lesson_lesson_id")
|
||||
)
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_lesson"))
|
||||
private Lesson lesson;
|
||||
|
||||
@Column(name = "canceled_at")
|
||||
private Instant canceledAt;
|
||||
|
||||
public UserLesson(User user, Lesson lesson) {
|
||||
this.user = user;
|
||||
this.lesson = lesson;
|
||||
this(null, user, lesson);
|
||||
}
|
||||
|
||||
public UserLesson(String id, User user, Lesson lesson) {
|
||||
super(id);
|
||||
this.user = Objects.requireNonNull(user, "user");
|
||||
this.lesson = Objects.requireNonNull(lesson, "lesson");
|
||||
}
|
||||
|
||||
public void cancel(Instant canceledAt) {
|
||||
if (this.canceledAt != null) {
|
||||
throw new IllegalStateException("registration is already canceled");
|
||||
}
|
||||
this.canceledAt = Objects.requireNonNull(canceledAt, "canceledAt");
|
||||
}
|
||||
|
||||
public boolean isCanceled() {
|
||||
return canceledAt != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.study.course_registration.entity;
|
||||
|
||||
import jakarta.persistence.Embeddable;
|
||||
import java.io.Serializable;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Embeddable
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class UserLessonId implements Serializable {
|
||||
private String userId;
|
||||
private String lessonId;
|
||||
|
||||
public UserLessonId(String userId, String lessonId) {
|
||||
this.userId = userId;
|
||||
this.lessonId = lessonId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.study.course_registration.enums;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
public enum RegistrationErrorCode {
|
||||
VALIDATION_ERROR(HttpStatus.BAD_REQUEST),
|
||||
USER_NOT_FOUND(HttpStatus.NOT_FOUND),
|
||||
LESSON_NOT_FOUND(HttpStatus.NOT_FOUND),
|
||||
SEMESTER_NOT_FOUND(HttpStatus.NOT_FOUND),
|
||||
REGISTRATION_NOT_FOUND(HttpStatus.NOT_FOUND),
|
||||
NOT_ELIGIBLE_GRADE(HttpStatus.FORBIDDEN),
|
||||
NOT_ELIGIBLE_ROLE(HttpStatus.FORBIDDEN),
|
||||
REGISTRATION_FORBIDDEN(HttpStatus.FORBIDDEN),
|
||||
REGISTRATION_PERIOD_CLOSED(HttpStatus.CONFLICT),
|
||||
ALREADY_REGISTERED(HttpStatus.CONFLICT),
|
||||
DUPLICATE_SUBJECT(HttpStatus.CONFLICT),
|
||||
SCHEDULE_CONFLICT(HttpStatus.CONFLICT),
|
||||
CREDIT_LIMIT_EXCEEDED(HttpStatus.CONFLICT),
|
||||
CAPACITY_EXCEEDED(HttpStatus.CONFLICT),
|
||||
ALREADY_CANCELED(HttpStatus.CONFLICT),
|
||||
LOCK_TIMEOUT(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
|
||||
private final HttpStatus status;
|
||||
|
||||
RegistrationErrorCode(HttpStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.study.course_registration.exception;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ErrorResponse(String code, String message, Instant timestamp) {
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.study.course_registration.exception;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
import org.springframework.dao.PessimisticLockingFailureException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import com.study.course_registration.enums.RegistrationErrorCode;
|
||||
|
||||
import jakarta.persistence.LockTimeoutException;
|
||||
import jakarta.persistence.PessimisticLockException;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
private final Clock clock;
|
||||
|
||||
public GlobalExceptionHandler(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@ExceptionHandler(RegistrationException.class)
|
||||
ResponseEntity<ErrorResponse> handleRegistrationException(RegistrationException exception) {
|
||||
return response(exception.getErrorCode(), exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException exception) {
|
||||
String message = exception.getBindingResult().getFieldErrors().stream()
|
||||
.findFirst()
|
||||
.map(error -> error.getField() + ": " + error.getDefaultMessage())
|
||||
.orElse("요청 값이 올바르지 않습니다.");
|
||||
return response(RegistrationErrorCode.VALIDATION_ERROR, message);
|
||||
}
|
||||
|
||||
@ExceptionHandler({ConstraintViolationException.class, HttpMessageNotReadableException.class})
|
||||
ResponseEntity<ErrorResponse> handleBadRequest(Exception exception) {
|
||||
return response(RegistrationErrorCode.VALIDATION_ERROR, "요청 값이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
@ExceptionHandler({LockTimeoutException.class, PessimisticLockException.class, PessimisticLockingFailureException.class})
|
||||
ResponseEntity<ErrorResponse> handleLockTimeout(Exception exception) {
|
||||
return response(RegistrationErrorCode.LOCK_TIMEOUT, "다른 요청이 처리 중입니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
|
||||
private ResponseEntity<ErrorResponse> response(RegistrationErrorCode code, String message) {
|
||||
return ResponseEntity.status(code.getStatus())
|
||||
.body(new ErrorResponse(code.name(), message, clock.instant()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.study.course_registration.exception;
|
||||
|
||||
import com.study.course_registration.enums.RegistrationErrorCode;
|
||||
|
||||
public class RegistrationException extends RuntimeException {
|
||||
private final RegistrationErrorCode errorCode;
|
||||
|
||||
public RegistrationException(RegistrationErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public RegistrationErrorCode getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,34 @@
|
||||
package com.study.course_registration.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.jpa.repository.QueryHints;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import jakarta.persistence.QueryHint;
|
||||
|
||||
public interface LessonRepository extends JpaRepository<Lesson, String> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
|
||||
@Query("select l from Lesson l where l.id = :id")
|
||||
Optional<Lesson> findByIdForUpdate(@Param("id") String id);
|
||||
|
||||
@EntityGraph(attributePaths = {"subject", "professor", "semester"})
|
||||
Page<Lesson> findBySemester_Id(String semesterId, Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = {"subject", "professor", "semester"})
|
||||
Page<Lesson> findBySemester_IdAndSubject_Id(String semesterId, String subjectId, Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = {"subject", "professor", "semester"})
|
||||
@Query("select l from Lesson l where l.id = :id")
|
||||
Optional<Lesson> findDetailedById(@Param("id") String id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.study.course_registration.repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
|
||||
public interface LessonScheduleRepository extends JpaRepository<LessonSchedule, String> {
|
||||
List<LessonSchedule> findByLesson_IdOrderByDayOfWeekAscStartTimeAsc(String lessonId);
|
||||
|
||||
List<LessonSchedule> findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(Collection<String> lessonIds);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.study.course_registration.repository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import com.study.course_registration.entity.Semester;
|
||||
|
||||
public interface SemesterRepository extends JpaRepository<Semester, String> {
|
||||
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(
|
||||
LocalDate startDate, LocalDate endDate);
|
||||
}
|
||||
@@ -1,10 +1,54 @@
|
||||
package com.study.course_registration.repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import com.study.course_registration.entity.UserLesson;
|
||||
import com.study.course_registration.entity.UserLessonId;
|
||||
|
||||
public interface UserLessonRepository extends JpaRepository<UserLesson, UserLessonId> {
|
||||
|
||||
public interface UserLessonRepository extends JpaRepository<UserLesson, String> {
|
||||
@EntityGraph(attributePaths = {"lesson", "lesson.subject", "lesson.professor", "lesson.semester"})
|
||||
@Query("""
|
||||
select ul from UserLesson ul
|
||||
where ul.user.id = :userId
|
||||
and ul.lesson.semester.id = :semesterId
|
||||
and ul.canceledAt is null
|
||||
order by ul.createdAt asc
|
||||
""")
|
||||
List<UserLesson> findActiveByUserIdAndSemesterId(
|
||||
@Param("userId") String userId,
|
||||
@Param("semesterId") String semesterId);
|
||||
|
||||
long countByLesson_IdAndCanceledAtIsNull(String lessonId);
|
||||
|
||||
@Query("""
|
||||
select ul.user.id as userId, ul.lesson.id as lessonId
|
||||
from UserLesson ul
|
||||
where ul.id = :registrationId
|
||||
""")
|
||||
Optional<RegistrationTarget> findTargetById(@Param("registrationId") String registrationId);
|
||||
|
||||
@Query("""
|
||||
select ul.lesson.id as lessonId, count(ul) as enrolledCount
|
||||
from UserLesson ul
|
||||
where ul.lesson.id in :lessonIds
|
||||
and ul.canceledAt is null
|
||||
group by ul.lesson.id
|
||||
""")
|
||||
List<LessonEnrollmentCount> countActiveByLessonIds(@Param("lessonIds") Collection<String> lessonIds);
|
||||
|
||||
interface RegistrationTarget {
|
||||
String getUserId();
|
||||
String getLessonId();
|
||||
}
|
||||
|
||||
interface LessonEnrollmentCount {
|
||||
String getLessonId();
|
||||
long getEnrolledCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
package com.study.course_registration.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.jpa.repository.QueryHints;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import com.study.course_registration.entity.User;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import jakarta.persistence.QueryHint;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, String> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
|
||||
@Query("select u from User u where u.id = :id")
|
||||
Optional<User> findByIdForUpdate(@Param("id") String id);
|
||||
}
|
||||
|
||||
@@ -1,91 +1,121 @@
|
||||
package com.study.course_registration.seeder;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import java.time.Clock;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
import com.study.course_registration.entity.Professor;
|
||||
import com.study.course_registration.entity.Semester;
|
||||
import com.study.course_registration.entity.Subject;
|
||||
import com.study.course_registration.entity.User;
|
||||
import com.study.course_registration.entity.UserLesson;
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
import com.study.course_registration.repository.LessonRepository;
|
||||
import com.study.course_registration.repository.UserRepository;
|
||||
import com.study.course_registration.repository.SubjectRepository;
|
||||
import com.study.course_registration.repository.LessonScheduleRepository;
|
||||
import com.study.course_registration.repository.ProfessorRepository;
|
||||
import com.study.course_registration.repository.UserLessonRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import com.study.course_registration.repository.SemesterRepository;
|
||||
import com.study.course_registration.repository.SubjectRepository;
|
||||
import com.study.course_registration.repository.UserRepository;
|
||||
|
||||
@Component
|
||||
@Profile ("local")
|
||||
@ConditionalOnProperty(name = "app.seed.enabled", havingValue = "true")
|
||||
public class DataSeeder implements CommandLineRunner {
|
||||
public static final String SEMESTER_ID = "00000000-0000-0000-0000-000000000001";
|
||||
public static final String STUDENT_GRADE_1_ID = "30000000-0000-0000-0000-000000000001";
|
||||
public static final String STUDENT_GRADE_2_ID = "30000000-0000-0000-0000-000000000002";
|
||||
public static final String POSTGRADUATE_ID = "30000000-0000-0000-0000-000000000003";
|
||||
public static final String DATA_STRUCTURES_01_ID = "40000000-0000-0000-0000-000000000001";
|
||||
public static final String DATA_STRUCTURES_02_ID = "40000000-0000-0000-0000-000000000002";
|
||||
public static final String OVERLAP_OS_ID = "40000000-0000-0000-0000-000000000003";
|
||||
public static final String CAPACITY_ONE_ID = "40000000-0000-0000-0000-000000000004";
|
||||
public static final String MIN_GRADE_ID = "40000000-0000-0000-0000-000000000005";
|
||||
public static final String POSTGRADUATE_ONLY_ID = "40000000-0000-0000-0000-000000000006";
|
||||
public static final String BOUNDARY_LESSON_ID = "40000000-0000-0000-0000-000000000007";
|
||||
|
||||
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final LessonRepository lessonRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final Clock clock;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final ProfessorRepository professorRepository;
|
||||
private final UserLessonRepository userLessonRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final LessonRepository lessonRepository;
|
||||
private final LessonScheduleRepository lessonScheduleRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public DataSeeder(UserRepository userRepository, LessonRepository lessonRepository, SubjectRepository subjectRepository, ProfessorRepository professorRepository, UserLessonRepository userLessonRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
public DataSeeder(Clock clock,
|
||||
SemesterRepository semesterRepository,
|
||||
ProfessorRepository professorRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
LessonRepository lessonRepository,
|
||||
LessonScheduleRepository lessonScheduleRepository,
|
||||
UserRepository userRepository) {
|
||||
this.clock = clock;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.professorRepository = professorRepository;
|
||||
this.userLessonRepository = userLessonRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.lessonScheduleRepository = lessonScheduleRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) throws Exception {
|
||||
if(userRepository.count() > 0) {
|
||||
public void run(String... args) {
|
||||
if (semesterRepository.existsById(SEMESTER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Instant now = clock.instant();
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
Semester semester = semesterRepository.save(new Semester(
|
||||
SEMESTER_ID, "2026-1", today.minusDays(30), today.plusDays(120),
|
||||
now.minusSeconds(86_400), now.plusSeconds(7 * 86_400), 18));
|
||||
|
||||
List<Professor> professors = professorRepository.saveAll(List.of(
|
||||
new Professor("김영한"),
|
||||
new Professor("최태영"),
|
||||
new Professor("조재한")
|
||||
));
|
||||
new Professor("10000000-0000-0000-0000-000000000001", "김영한"),
|
||||
new Professor("10000000-0000-0000-0000-000000000002", "최태영"),
|
||||
new Professor("10000000-0000-0000-0000-000000000003", "조재한")));
|
||||
|
||||
List<Subject> subjects = subjectRepository.saveAll(List.of(
|
||||
new Subject("자바 프로그래밍", "CS201", "자바 프로그래밍 기초"),
|
||||
new Subject("자료구조", "CS202", "자료구조 및 알고리즘"),
|
||||
new Subject("데이터베이스", "CS203", "데이터베이스 시스템")
|
||||
));
|
||||
new Subject("20000000-0000-0000-0000-000000000001", "자바 프로그래밍", "CS201", "자바 프로그래밍 기초", 3),
|
||||
new Subject("20000000-0000-0000-0000-000000000002", "자료구조", "CS202", "자료구조 및 알고리즘", 3),
|
||||
new Subject("20000000-0000-0000-0000-000000000003", "데이터베이스", "CS203", "데이터베이스 시스템", 3),
|
||||
new Subject("20000000-0000-0000-0000-000000000004", "컴퓨터 네트워크", "CS204", "네트워크 기초", 3),
|
||||
new Subject("20000000-0000-0000-0000-000000000005", "운영체제", "CS301", "운영체제 핵심", 3),
|
||||
new Subject("20000000-0000-0000-0000-000000000006", "대학원 세미나", "CS401", "대학원 전용 세미나", 3)));
|
||||
|
||||
Instant base = Instant.now().truncatedTo(ChronoUnit.HOURS);
|
||||
List<Lesson> lessons = lessonRepository.saveAll(List.of(
|
||||
new Lesson("자바 프로그래밍", subjects.get(0), professors.get(0), kst(3, 2, 9), kst(3, 2, 11)),
|
||||
new Lesson("자료구조", subjects.get(1), professors.get(1), kst(3, 2, 13), kst(3, 2, 17)),
|
||||
new Lesson("데이터베이스", subjects.get(2), professors.get(2), kst(3, 3, 10), kst(3, 3, 14))
|
||||
));
|
||||
Lesson data01 = new Lesson(DATA_STRUCTURES_01_ID, "자료구조 01분반", subjects.get(1), professors.get(0), semester, 30, null, null);
|
||||
Lesson data02 = new Lesson(DATA_STRUCTURES_02_ID, "자료구조 02분반", subjects.get(1), professors.get(1), semester, 30, null, null);
|
||||
Lesson os = new Lesson(OVERLAP_OS_ID, "운영체제", subjects.get(4), professors.get(1), semester, 30, null, null);
|
||||
Lesson capacityOne = new Lesson(CAPACITY_ONE_ID, "데이터베이스 소수정예", subjects.get(2), professors.get(2), semester, 1, null, null);
|
||||
Lesson minGrade = new Lesson(MIN_GRADE_ID, "고급 네트워크", subjects.get(3), professors.get(2), semester, 30, 3L, null);
|
||||
Lesson postgraduate = new Lesson(POSTGRADUATE_ONLY_ID, "대학원 세미나", subjects.get(5), professors.get(0), semester, 20, null, UserRole.POSTGRADUATE);
|
||||
Lesson boundary = new Lesson(BOUNDARY_LESSON_ID, "자바 프로그래밍", subjects.get(0), professors.get(0), semester, 30, null, null);
|
||||
lessonRepository.saveAll(List.of(data01, data02, os, capacityOne, minGrade, postgraduate, boundary));
|
||||
|
||||
List<User> users = userRepository.saveAll(List.of(
|
||||
new User("학생1", 1L, UserRole.STUDENT),
|
||||
new User("학생2", 2L, UserRole.STUDENT),
|
||||
new User("대학원생", 3L, UserRole.POSTGRADUATE)
|
||||
));
|
||||
lessonScheduleRepository.saveAll(List.of(
|
||||
schedule("50000000-0000-0000-0000-000000000001", data01, DayOfWeek.MONDAY, 9, 11),
|
||||
schedule("50000000-0000-0000-0000-000000000002", data02, DayOfWeek.TUESDAY, 9, 11),
|
||||
schedule("50000000-0000-0000-0000-000000000003", os, DayOfWeek.MONDAY, 10, 12),
|
||||
schedule("50000000-0000-0000-0000-000000000004", capacityOne, DayOfWeek.WEDNESDAY, 9, 11),
|
||||
schedule("50000000-0000-0000-0000-000000000005", minGrade, DayOfWeek.THURSDAY, 9, 11),
|
||||
schedule("50000000-0000-0000-0000-000000000006", postgraduate, DayOfWeek.FRIDAY, 9, 11),
|
||||
schedule("50000000-0000-0000-0000-000000000007", boundary, DayOfWeek.MONDAY, 11, 13)));
|
||||
|
||||
List<UserLesson> userLessons = userLessonRepository.saveAll(List.of(
|
||||
new UserLesson(users.get(0), lessons.get(0)),
|
||||
new UserLesson(users.get(0), lessons.get(1)),
|
||||
new UserLesson(users.get(1), lessons.get(1)),
|
||||
new UserLesson(users.get(2), lessons.get(2))
|
||||
));
|
||||
userRepository.saveAll(List.of(
|
||||
new User(STUDENT_GRADE_1_ID, "학생1", 1L, UserRole.STUDENT),
|
||||
new User(STUDENT_GRADE_2_ID, "학생2", 2L, UserRole.STUDENT),
|
||||
new User(POSTGRADUATE_ID, "대학원생", 3L, UserRole.POSTGRADUATE)));
|
||||
}
|
||||
|
||||
private Instant kst(int month, int day, int hour) {
|
||||
return ZonedDateTime.of(2026, month, day, hour, 0, 0, 0, KST).toInstant();
|
||||
private static LessonSchedule schedule(String id, Lesson lesson, DayOfWeek day, int startHour, int endHour) {
|
||||
return new LessonSchedule(id, lesson, day, LocalTime.of(startHour, 0), LocalTime.of(endHour, 0));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+163
-16
@@ -1,36 +1,183 @@
|
||||
package com.study.course_registration.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.study.course_registration.dto.RequestDto;
|
||||
import com.study.course_registration.dto.ResponseDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.study.course_registration.dto.ScheduleResponse;
|
||||
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
|
||||
import com.study.course_registration.dto.registration.CourseRegistrationResponse;
|
||||
import com.study.course_registration.dto.registration.RegistrationItemResponse;
|
||||
import com.study.course_registration.dto.registration.RegistrationListResponse;
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
import com.study.course_registration.entity.Semester;
|
||||
import com.study.course_registration.entity.User;
|
||||
import com.study.course_registration.entity.UserLesson;
|
||||
import com.study.course_registration.enums.RegistrationErrorCode;
|
||||
import com.study.course_registration.exception.RegistrationException;
|
||||
import com.study.course_registration.repository.LessonRepository;
|
||||
import com.study.course_registration.repository.LessonScheduleRepository;
|
||||
import com.study.course_registration.repository.SemesterRepository;
|
||||
import com.study.course_registration.repository.UserLessonRepository;
|
||||
import com.study.course_registration.repository.UserRepository;
|
||||
import com.study.course_registration.repository.SubjectRepository;
|
||||
import com.study.course_registration.repository.ProfessorRepository;
|
||||
import com.study.course_registration.service.policy.RegistrationValidator;
|
||||
|
||||
@Service
|
||||
public class CourseRegistrationService {
|
||||
private final Clock clock;
|
||||
private final UserRepository userRepository;
|
||||
private final LessonRepository lessonRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final ProfessorRepository professorRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final LessonScheduleRepository lessonScheduleRepository;
|
||||
private final UserLessonRepository userLessonRepository;
|
||||
private final RegistrationValidator validator;
|
||||
|
||||
public CourseRegistrationService(UserRepository userRepository, LessonRepository lessonRepository, SubjectRepository subjectRepository, ProfessorRepository professorRepository, UserLessonRepository userLessonRepository) {
|
||||
public CourseRegistrationService(Clock clock,
|
||||
UserRepository userRepository,
|
||||
LessonRepository lessonRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
LessonScheduleRepository lessonScheduleRepository,
|
||||
UserLessonRepository userLessonRepository,
|
||||
RegistrationValidator validator) {
|
||||
this.clock = clock;
|
||||
this.userRepository = userRepository;
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.professorRepository = professorRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.lessonScheduleRepository = lessonScheduleRepository;
|
||||
this.userLessonRepository = userLessonRepository;
|
||||
this.validator = validator;
|
||||
}
|
||||
|
||||
public ResponseDto registerCourse(RequestDto requestDto) {
|
||||
|
||||
return new ResponseDto();
|
||||
@Transactional
|
||||
public CourseRegistrationResponse register(String userId, CourseRegistrationRequest request) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));
|
||||
Lesson lesson = lessonRepository.findById(request.lessonId())
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
|
||||
|
||||
var now = clock.instant();
|
||||
validator.validatePreconditions(user, lesson, now);
|
||||
|
||||
// Lock order is a system invariant: User -> Lesson.
|
||||
User lockedUser = userRepository.findByIdForUpdate(userId)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));
|
||||
Lesson lockedLesson = lessonRepository.findByIdForUpdate(request.lessonId())
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
|
||||
|
||||
List<UserLesson> activeRegistrations = userLessonRepository.findActiveByUserIdAndSemesterId(
|
||||
lockedUser.getId(), lockedLesson.getSemester().getId());
|
||||
Map<String, List<LessonSchedule>> schedules = loadSchedules(activeRegistrations, lockedLesson);
|
||||
long enrolledCount = userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lockedLesson.getId());
|
||||
|
||||
validator.validateLocked(lockedLesson, activeRegistrations, schedules, enrolledCount);
|
||||
int totalCredits = validator.totalCreditsAfter(lockedLesson, activeRegistrations);
|
||||
|
||||
UserLesson registration = userLessonRepository.saveAndFlush(new UserLesson(lockedUser, lockedLesson));
|
||||
return new CourseRegistrationResponse(
|
||||
registration.getId(),
|
||||
lockedUser.getId(),
|
||||
lockedLesson.getId(),
|
||||
lockedLesson.getName(),
|
||||
lockedLesson.getSubject().getCode(),
|
||||
lockedLesson.getSubject().getCredit(),
|
||||
registration.getCreatedAt(),
|
||||
totalCredits);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public RegistrationListResponse getRegistrations(String userId, String semesterId) {
|
||||
if (!userRepository.existsById(userId)) {
|
||||
throw error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
Semester semester = resolveSemester(semesterId);
|
||||
List<UserLesson> activeRegistrations = userLessonRepository.findActiveByUserIdAndSemesterId(userId, semester.getId());
|
||||
|
||||
Set<String> lessonIds = activeRegistrations.stream()
|
||||
.map(UserLesson::getLesson)
|
||||
.map(Lesson::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, List<LessonSchedule>> schedulesByLesson = groupSchedules(
|
||||
lessonIds.isEmpty() ? List.of() : lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds));
|
||||
|
||||
List<RegistrationItemResponse> items = activeRegistrations.stream()
|
||||
.map(registration -> {
|
||||
Lesson lesson = registration.getLesson();
|
||||
List<ScheduleResponse> schedules = schedulesByLesson.getOrDefault(lesson.getId(), List.of()).stream()
|
||||
.map(ScheduleResponse::from)
|
||||
.toList();
|
||||
return new RegistrationItemResponse(
|
||||
registration.getId(), lesson.getId(), lesson.getName(),
|
||||
lesson.getSubject().getCode(), lesson.getSubject().getName(), lesson.getSubject().getCredit(),
|
||||
lesson.getProfessor().getName(), schedules, registration.getCreatedAt());
|
||||
})
|
||||
.toList();
|
||||
|
||||
return new RegistrationListResponse(
|
||||
userId, semester.getId(), semester.getName(), validator.currentCredits(activeRegistrations),
|
||||
semester.getMaxCredits(), items);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void cancel(String userId, String registrationId) {
|
||||
UserLessonRepository.RegistrationTarget target = userLessonRepository.findTargetById(registrationId)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.REGISTRATION_NOT_FOUND, "수강신청 건을 찾을 수 없습니다."));
|
||||
if (!target.getUserId().equals(userId)) {
|
||||
throw error(RegistrationErrorCode.REGISTRATION_FORBIDDEN, "다른 사용자의 수강신청은 취소할 수 없습니다.");
|
||||
}
|
||||
|
||||
// Mutation paths use the same fixed lock order as registration.
|
||||
userRepository.findByIdForUpdate(userId)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));
|
||||
Lesson lesson = lessonRepository.findByIdForUpdate(target.getLessonId())
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
|
||||
UserLesson registration = userLessonRepository.findById(registrationId)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.REGISTRATION_NOT_FOUND, "수강신청 건을 찾을 수 없습니다."));
|
||||
|
||||
if (registration.isCanceled()) {
|
||||
throw error(RegistrationErrorCode.ALREADY_CANCELED, "이미 취소된 수강신청입니다.");
|
||||
}
|
||||
var now = clock.instant();
|
||||
validator.validateRegistrationPeriod(lesson, now);
|
||||
registration.cancel(now);
|
||||
}
|
||||
|
||||
private Semester resolveSemester(String semesterId) {
|
||||
if (semesterId != null && !semesterId.isBlank()) {
|
||||
return semesterRepository.findById(semesterId)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "학기를 찾을 수 없습니다."));
|
||||
}
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
return semesterRepository
|
||||
.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(today, today)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "현재 진행 중인 학기를 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
private Map<String, List<LessonSchedule>> loadSchedules(List<UserLesson> registrations, Lesson candidate) {
|
||||
Set<String> lessonIds = registrations.stream()
|
||||
.map(UserLesson::getLesson)
|
||||
.map(Lesson::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
lessonIds.add(candidate.getId());
|
||||
return groupSchedules(lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds));
|
||||
}
|
||||
|
||||
private static Map<String, List<LessonSchedule>> groupSchedules(List<LessonSchedule> schedules) {
|
||||
return schedules.stream().collect(Collectors.groupingBy(
|
||||
schedule -> schedule.getLesson().getId(),
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()));
|
||||
}
|
||||
|
||||
private static RegistrationException error(RegistrationErrorCode code, String message) {
|
||||
return new RegistrationException(code, message);
|
||||
}
|
||||
}
|
||||
// 저장 전 log
|
||||
// 검증 로직
|
||||
//
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.study.course_registration.service;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.study.course_registration.dto.ScheduleResponse;
|
||||
import com.study.course_registration.dto.lesson.LessonDetailResponse;
|
||||
import com.study.course_registration.dto.lesson.LessonItemResponse;
|
||||
import com.study.course_registration.dto.lesson.LessonListResponse;
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
import com.study.course_registration.entity.Semester;
|
||||
import com.study.course_registration.enums.RegistrationErrorCode;
|
||||
import com.study.course_registration.exception.RegistrationException;
|
||||
import com.study.course_registration.repository.LessonRepository;
|
||||
import com.study.course_registration.repository.LessonScheduleRepository;
|
||||
import com.study.course_registration.repository.SemesterRepository;
|
||||
import com.study.course_registration.repository.UserLessonRepository;
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
public class LessonQueryService {
|
||||
private final Clock clock;
|
||||
private final LessonRepository lessonRepository;
|
||||
private final LessonScheduleRepository lessonScheduleRepository;
|
||||
private final UserLessonRepository userLessonRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
|
||||
public LessonQueryService(Clock clock,
|
||||
LessonRepository lessonRepository,
|
||||
LessonScheduleRepository lessonScheduleRepository,
|
||||
UserLessonRepository userLessonRepository,
|
||||
SemesterRepository semesterRepository) {
|
||||
this.clock = clock;
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.lessonScheduleRepository = lessonScheduleRepository;
|
||||
this.userLessonRepository = userLessonRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
}
|
||||
|
||||
public LessonListResponse getLessons(String semesterId, String subjectId, int page, int size) {
|
||||
Semester semester = resolveSemester(semesterId);
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.asc("name"), Sort.Order.asc("id")));
|
||||
Page<Lesson> lessonPage = subjectId == null || subjectId.isBlank()
|
||||
? lessonRepository.findBySemester_Id(semester.getId(), pageable)
|
||||
: lessonRepository.findBySemester_IdAndSubject_Id(semester.getId(), subjectId, pageable);
|
||||
|
||||
List<String> lessonIds = lessonPage.getContent().stream().map(Lesson::getId).toList();
|
||||
Map<String, List<LessonSchedule>> schedules = loadSchedules(lessonIds);
|
||||
Map<String, Long> counts = loadCounts(lessonIds);
|
||||
|
||||
List<LessonItemResponse> items = lessonPage.getContent().stream()
|
||||
.map(lesson -> toItem(lesson, schedules.getOrDefault(lesson.getId(), List.of()), counts.getOrDefault(lesson.getId(), 0L)))
|
||||
.toList();
|
||||
|
||||
return new LessonListResponse(page, size, lessonPage.getTotalElements(), items);
|
||||
}
|
||||
|
||||
public LessonDetailResponse getLesson(String lessonId) {
|
||||
Lesson lesson = lessonRepository.findDetailedById(lessonId)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
|
||||
List<ScheduleResponse> schedules = lessonScheduleRepository.findByLesson_IdOrderByDayOfWeekAscStartTimeAsc(lessonId)
|
||||
.stream().map(ScheduleResponse::from).toList();
|
||||
long enrolledCount = userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lessonId);
|
||||
|
||||
return new LessonDetailResponse(
|
||||
lesson.getId(), lesson.getName(), lesson.getSubject().getCode(), lesson.getSubject().getName(),
|
||||
lesson.getSubject().getDescription(), lesson.getSubject().getCredit(), lesson.getProfessor().getName(),
|
||||
lesson.getSemester().getName(), lesson.getCapacity(), enrolledCount, lesson.getMinGrade(), lesson.getAllowedRole(),
|
||||
lesson.getSemester().getRegistrationStartAt(), lesson.getSemester().getRegistrationEndAt(), schedules);
|
||||
}
|
||||
|
||||
private LessonItemResponse toItem(Lesson lesson, List<LessonSchedule> schedules, long enrolledCount) {
|
||||
return new LessonItemResponse(
|
||||
lesson.getId(), lesson.getName(), lesson.getSubject().getCode(), lesson.getSubject().getName(),
|
||||
lesson.getSubject().getCredit(), lesson.getProfessor().getName(), lesson.getCapacity(), enrolledCount,
|
||||
lesson.getMinGrade(), lesson.getAllowedRole(), schedules.stream().map(ScheduleResponse::from).toList());
|
||||
}
|
||||
|
||||
private Map<String, List<LessonSchedule>> loadSchedules(List<String> lessonIds) {
|
||||
if (lessonIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds).stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
schedule -> schedule.getLesson().getId(), LinkedHashMap::new, Collectors.toList()));
|
||||
}
|
||||
|
||||
private Map<String, Long> loadCounts(List<String> lessonIds) {
|
||||
if (lessonIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return userLessonRepository.countActiveByLessonIds(lessonIds).stream()
|
||||
.collect(Collectors.toMap(
|
||||
UserLessonRepository.LessonEnrollmentCount::getLessonId,
|
||||
UserLessonRepository.LessonEnrollmentCount::getEnrolledCount,
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
private Semester resolveSemester(String semesterId) {
|
||||
if (semesterId != null && !semesterId.isBlank()) {
|
||||
return semesterRepository.findById(semesterId)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "학기를 찾을 수 없습니다."));
|
||||
}
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(today, today)
|
||||
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "현재 진행 중인 학기를 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
private static RegistrationException error(RegistrationErrorCode code, String message) {
|
||||
return new RegistrationException(code, message);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package com.study.course_registration.service.policy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
import com.study.course_registration.entity.User;
|
||||
import com.study.course_registration.entity.UserLesson;
|
||||
import com.study.course_registration.enums.RegistrationErrorCode;
|
||||
import com.study.course_registration.exception.RegistrationException;
|
||||
|
||||
@Component
|
||||
public class RegistrationValidator {
|
||||
private final ScheduleConflictChecker scheduleConflictChecker;
|
||||
|
||||
public RegistrationValidator(ScheduleConflictChecker scheduleConflictChecker) {
|
||||
this.scheduleConflictChecker = scheduleConflictChecker;
|
||||
}
|
||||
|
||||
public void validatePreconditions(User user, Lesson lesson, Instant now) {
|
||||
validateRegistrationPeriod(lesson, now);
|
||||
if (lesson.getMinGrade() != null && user.getGrade() < lesson.getMinGrade()) {
|
||||
throw error(RegistrationErrorCode.NOT_ELIGIBLE_GRADE,
|
||||
"수강 가능한 학년이 아닙니다. (현재 " + user.getGrade() + "학년, 최소 " + lesson.getMinGrade() + "학년)");
|
||||
}
|
||||
if (lesson.getAllowedRole() != null && user.getRole() != lesson.getAllowedRole()) {
|
||||
throw error(RegistrationErrorCode.NOT_ELIGIBLE_ROLE,
|
||||
"수강 가능한 역할이 아닙니다. (필요 역할: " + lesson.getAllowedRole() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void validateRegistrationPeriod(Lesson lesson, Instant now) {
|
||||
if (now.isBefore(lesson.getSemester().getRegistrationStartAt())
|
||||
|| now.isAfter(lesson.getSemester().getRegistrationEndAt())) {
|
||||
throw error(RegistrationErrorCode.REGISTRATION_PERIOD_CLOSED, "수강신청 기간이 아닙니다.");
|
||||
}
|
||||
}
|
||||
|
||||
public void validateLocked(Lesson lesson,
|
||||
List<UserLesson> activeRegistrations,
|
||||
Map<String, List<LessonSchedule>> schedulesByLessonId,
|
||||
long enrolledCount) {
|
||||
if (activeRegistrations.stream().anyMatch(registration -> registration.getLesson().getId().equals(lesson.getId()))) {
|
||||
throw error(RegistrationErrorCode.ALREADY_REGISTERED, "이미 신청한 강의입니다.");
|
||||
}
|
||||
|
||||
if (activeRegistrations.stream().anyMatch(registration ->
|
||||
registration.getLesson().getSubject().getId().equals(lesson.getSubject().getId()))) {
|
||||
throw error(RegistrationErrorCode.DUPLICATE_SUBJECT, "같은 과목의 다른 분반을 이미 신청했습니다.");
|
||||
}
|
||||
|
||||
List<LessonSchedule> candidateSchedules = schedulesByLessonId.getOrDefault(lesson.getId(), List.of());
|
||||
for (UserLesson registration : activeRegistrations) {
|
||||
List<LessonSchedule> existingSchedules = schedulesByLessonId.getOrDefault(registration.getLesson().getId(), List.of());
|
||||
for (LessonSchedule candidate : candidateSchedules) {
|
||||
for (LessonSchedule existing : existingSchedules) {
|
||||
if (scheduleConflictChecker.overlaps(candidate, existing)) {
|
||||
throw error(RegistrationErrorCode.SCHEDULE_CONFLICT,
|
||||
"기존 수업과 시간이 겹칩니다. (" + candidate.getDayOfWeek() + " "
|
||||
+ candidate.getStartTime() + "-" + candidate.getEndTime() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int currentCredits = currentCredits(activeRegistrations);
|
||||
int requestedCredits = lesson.getSubject().getCredit();
|
||||
int maxCredits = lesson.getSemester().getMaxCredits();
|
||||
if (currentCredits + requestedCredits > maxCredits) {
|
||||
throw error(RegistrationErrorCode.CREDIT_LIMIT_EXCEEDED,
|
||||
"신청 학점이 상한을 넘습니다. (" + currentCredits + " + " + requestedCredits + " > " + maxCredits + ")");
|
||||
}
|
||||
|
||||
if (enrolledCount >= lesson.getCapacity()) {
|
||||
throw error(RegistrationErrorCode.CAPACITY_EXCEEDED,
|
||||
"정원이 가득 찼습니다. (" + enrolledCount + "/" + lesson.getCapacity() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
public int totalCreditsAfter(Lesson lesson, List<UserLesson> activeRegistrations) {
|
||||
return currentCredits(activeRegistrations) + lesson.getSubject().getCredit();
|
||||
}
|
||||
|
||||
public int currentCredits(List<UserLesson> activeRegistrations) {
|
||||
return activeRegistrations.stream()
|
||||
.map(UserLesson::getLesson)
|
||||
.map(Lesson::getSubject)
|
||||
.mapToInt(subject -> subject.getCredit())
|
||||
.sum();
|
||||
}
|
||||
|
||||
private static RegistrationException error(RegistrationErrorCode code, String message) {
|
||||
return new RegistrationException(code, message);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.study.course_registration.service.policy;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
|
||||
@Component
|
||||
public class ScheduleConflictChecker {
|
||||
public boolean overlaps(LessonSchedule left, LessonSchedule right) {
|
||||
return left.getDayOfWeek() == right.getDayOfWeek()
|
||||
&& left.getStartTime().isBefore(right.getEndTime())
|
||||
&& right.getStartTime().isBefore(left.getEndTime());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:course_registration}
|
||||
username: ${DB_USERNAME:course}
|
||||
password: ${DB_PASSWORD:course}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
h2:
|
||||
console:
|
||||
enabled: false
|
||||
|
||||
app:
|
||||
seed:
|
||||
enabled: ${APP_SEED_ENABLED:false}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:course_db
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
path: /h2-console
|
||||
datasource:
|
||||
url: jdbc:h2:mem:course_db;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;LOCK_TIMEOUT=3000
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
path: /h2-console
|
||||
|
||||
app:
|
||||
seed:
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.hibernate.SQL: debug
|
||||
level:
|
||||
org.hibernate.SQL: debug
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
show-sql: false
|
||||
h2:
|
||||
console:
|
||||
enabled: false
|
||||
|
||||
app:
|
||||
seed:
|
||||
enabled: false
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: ${SWAGGER_ENABLED:false}
|
||||
swagger-ui:
|
||||
enabled: ${SWAGGER_ENABLED:false}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
server:
|
||||
application:
|
||||
name: ${APP_NAME:course-registration}
|
||||
profiles:
|
||||
active: ${APP_PROFILE:local}
|
||||
address: ${SERVER_ADDRESS:127.0.0.1}
|
||||
port: ${SERVER_PORT:8080}
|
||||
|
||||
spring:
|
||||
jackson:
|
||||
time-zone: UTC
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
path: /h2-console
|
||||
datasource:
|
||||
url: jdbc:h2:mem:course_db
|
||||
username: sa
|
||||
password:
|
||||
driver-class-name: org.h2.Driver
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
application:
|
||||
name: ${APP_NAME:course-registration}
|
||||
profiles:
|
||||
default: local
|
||||
jackson:
|
||||
time-zone: UTC
|
||||
jpa:
|
||||
open-in-view: false
|
||||
|
||||
logging.level:
|
||||
org.hibernate.SQL: debug
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package com.study.course_registration.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
import com.study.course_registration.entity.Professor;
|
||||
import com.study.course_registration.entity.Semester;
|
||||
import com.study.course_registration.entity.Subject;
|
||||
import com.study.course_registration.entity.User;
|
||||
import com.study.course_registration.enums.RegistrationErrorCode;
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
import com.study.course_registration.exception.RegistrationException;
|
||||
import com.study.course_registration.repository.*;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class CourseRegistrationConcurrencyIntegrationTest {
|
||||
@Autowired CourseRegistrationService service;
|
||||
@Autowired UserLessonRepository userLessonRepository;
|
||||
@Autowired LessonScheduleRepository lessonScheduleRepository;
|
||||
@Autowired LessonRepository lessonRepository;
|
||||
@Autowired SubjectRepository subjectRepository;
|
||||
@Autowired ProfessorRepository professorRepository;
|
||||
@Autowired SemesterRepository semesterRepository;
|
||||
@Autowired UserRepository userRepository;
|
||||
|
||||
private ExecutorService executor;
|
||||
private Semester semester;
|
||||
private Professor professor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
clearData();
|
||||
Instant now = Instant.now();
|
||||
semester = semesterRepository.save(new Semester("semester", "2026-1", LocalDate.now().minusDays(1),
|
||||
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), 18));
|
||||
professor = professorRepository.save(new Professor("professor", "교수"));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws InterruptedException {
|
||||
if (executor != null) {
|
||||
executor.shutdownNow();
|
||||
executor.awaitTermination(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void capacityOneWithTenConcurrentUsersHasExactlyOneSuccess() throws Exception {
|
||||
Lesson lesson = lesson("lesson-cap1", "subject-cap1", 3, 1, DayOfWeek.MONDAY, 9, 11);
|
||||
List<User> users = users(10, "cap1-user-");
|
||||
List<RegistrationErrorCode> results = runConcurrent(users.stream()
|
||||
.<ThrowingAction>map(user -> () -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId())))
|
||||
.toList());
|
||||
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
|
||||
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.CAPACITY_EXCEEDED)).hasSize(9);
|
||||
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lesson.getId())).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameStudentConcurrentLessonsCannotExceedCreditLimit() throws Exception {
|
||||
replaceSemesterWithMaxCredits(3);
|
||||
User user = userRepository.save(new User("credit-user", "학생", 2L, UserRole.STUDENT));
|
||||
Lesson lessonA = lesson("credit-a", "credit-subject-a", 3, 30, DayOfWeek.MONDAY, 9, 11);
|
||||
Lesson lessonB = lesson("credit-b", "credit-subject-b", 3, 30, DayOfWeek.TUESDAY, 9, 11);
|
||||
List<RegistrationErrorCode> results = runConcurrent(List.of(
|
||||
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId())),
|
||||
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonB.getId()))));
|
||||
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
|
||||
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.CREDIT_LIMIT_EXCEEDED)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameStudentConcurrentOverlappingLessonsRejectOneConflict() throws Exception {
|
||||
User user = userRepository.save(new User("schedule-user", "학생", 2L, UserRole.STUDENT));
|
||||
Lesson lessonA = lesson("schedule-a", "schedule-subject-a", 3, 30, DayOfWeek.MONDAY, 9, 11);
|
||||
Lesson lessonB = lesson("schedule-b", "schedule-subject-b", 3, 30, DayOfWeek.MONDAY, 10, 12);
|
||||
List<RegistrationErrorCode> results = runConcurrent(List.of(
|
||||
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId())),
|
||||
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonB.getId()))));
|
||||
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
|
||||
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.SCHEDULE_CONFLICT)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameStudentSameLessonConcurrentRequestsRejectDuplicate() throws Exception {
|
||||
User user = userRepository.save(new User("duplicate-user", "학생", 2L, UserRole.STUDENT));
|
||||
Lesson lesson = lesson("duplicate-lesson", "duplicate-subject", 3, 30, DayOfWeek.WEDNESDAY, 9, 11);
|
||||
List<RegistrationErrorCode> results = runConcurrent(List.of(
|
||||
() -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId())),
|
||||
() -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId()))));
|
||||
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
|
||||
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.ALREADY_REGISTERED)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void capacityThirtyWithFiftyConcurrentUsersStopsAtThirty() throws Exception {
|
||||
Lesson lesson = lesson("lesson-cap30", "subject-cap30", 3, 30, DayOfWeek.THURSDAY, 9, 11);
|
||||
List<User> users = users(50, "cap30-user-");
|
||||
List<RegistrationErrorCode> results = runConcurrent(users.stream()
|
||||
.<ThrowingAction>map(user -> () -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId())))
|
||||
.toList());
|
||||
assertThat(results.stream().filter(code -> code == null)).hasSize(30);
|
||||
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.CAPACITY_EXCEEDED)).hasSize(20);
|
||||
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lesson.getId())).isEqualTo(30);
|
||||
}
|
||||
|
||||
private void replaceSemesterWithMaxCredits(int maxCredits) {
|
||||
userLessonRepository.deleteAll();
|
||||
lessonScheduleRepository.deleteAll();
|
||||
lessonRepository.deleteAll();
|
||||
semesterRepository.deleteAll();
|
||||
Instant now = Instant.now();
|
||||
semester = semesterRepository.save(new Semester("semester", "2026-1", LocalDate.now().minusDays(1),
|
||||
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), maxCredits));
|
||||
}
|
||||
|
||||
private void clearData() {
|
||||
userLessonRepository.deleteAll();
|
||||
lessonScheduleRepository.deleteAll();
|
||||
lessonRepository.deleteAll();
|
||||
subjectRepository.deleteAll();
|
||||
professorRepository.deleteAll();
|
||||
semesterRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
}
|
||||
|
||||
private Lesson lesson(String lessonId, String subjectId, int credit, int capacity,
|
||||
DayOfWeek day, int startHour, int endHour) {
|
||||
Subject subject = subjectRepository.save(new Subject(subjectId, subjectId, subjectId, subjectId, credit));
|
||||
Lesson lesson = lessonRepository.save(new Lesson(lessonId, lessonId, subject, professor, semester, capacity, null, null));
|
||||
lessonScheduleRepository.save(new LessonSchedule("schedule-" + lessonId, lesson, day,
|
||||
LocalTime.of(startHour, 0), LocalTime.of(endHour, 0)));
|
||||
return lesson;
|
||||
}
|
||||
|
||||
private List<User> users(int count, String prefix) {
|
||||
List<User> users = new ArrayList<>();
|
||||
for (int index = 0; index < count; index++) {
|
||||
users.add(new User(prefix + index, "학생" + index, 2L, UserRole.STUDENT));
|
||||
}
|
||||
return userRepository.saveAll(users);
|
||||
}
|
||||
|
||||
private List<RegistrationErrorCode> runConcurrent(List<ThrowingAction> actions) throws Exception {
|
||||
executor = Executors.newFixedThreadPool(actions.size());
|
||||
CountDownLatch ready = new CountDownLatch(actions.size());
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
CountDownLatch done = new CountDownLatch(actions.size());
|
||||
List<RegistrationErrorCode> results = Collections.synchronizedList(new ArrayList<>());
|
||||
List<Throwable> unexpected = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
for (ThrowingAction action : actions) {
|
||||
executor.submit(() -> {
|
||||
ready.countDown();
|
||||
try {
|
||||
start.await();
|
||||
action.run();
|
||||
results.add(null);
|
||||
} catch (RegistrationException exception) {
|
||||
results.add(exception.getErrorCode());
|
||||
} catch (Throwable throwable) {
|
||||
unexpected.add(throwable);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(unexpected).isEmpty();
|
||||
return results;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package com.study.course_registration.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
|
||||
import com.study.course_registration.dto.registration.CourseRegistrationResponse;
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
import com.study.course_registration.entity.Professor;
|
||||
import com.study.course_registration.entity.Semester;
|
||||
import com.study.course_registration.entity.Subject;
|
||||
import com.study.course_registration.entity.User;
|
||||
import com.study.course_registration.enums.RegistrationErrorCode;
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
import com.study.course_registration.exception.RegistrationException;
|
||||
import com.study.course_registration.repository.LessonRepository;
|
||||
import com.study.course_registration.repository.LessonScheduleRepository;
|
||||
import com.study.course_registration.repository.ProfessorRepository;
|
||||
import com.study.course_registration.repository.SemesterRepository;
|
||||
import com.study.course_registration.repository.SubjectRepository;
|
||||
import com.study.course_registration.repository.UserLessonRepository;
|
||||
import com.study.course_registration.repository.UserRepository;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class CourseRegistrationServiceIntegrationTest {
|
||||
@Autowired CourseRegistrationService service;
|
||||
@Autowired UserLessonRepository userLessonRepository;
|
||||
@Autowired LessonScheduleRepository lessonScheduleRepository;
|
||||
@Autowired LessonRepository lessonRepository;
|
||||
@Autowired SubjectRepository subjectRepository;
|
||||
@Autowired ProfessorRepository professorRepository;
|
||||
@Autowired SemesterRepository semesterRepository;
|
||||
@Autowired UserRepository userRepository;
|
||||
|
||||
private Semester semester;
|
||||
private User user;
|
||||
private Subject subjectA;
|
||||
private Subject subjectB;
|
||||
private Professor professor;
|
||||
private Lesson lessonA;
|
||||
private Lesson lessonB;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userLessonRepository.deleteAll();
|
||||
lessonScheduleRepository.deleteAll();
|
||||
lessonRepository.deleteAll();
|
||||
subjectRepository.deleteAll();
|
||||
professorRepository.deleteAll();
|
||||
semesterRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
|
||||
Instant now = Instant.now();
|
||||
semester = semesterRepository.save(new Semester("semester", "2026-1", LocalDate.now().minusDays(1),
|
||||
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), 18));
|
||||
professor = professorRepository.save(new Professor("professor", "교수"));
|
||||
subjectA = subjectRepository.save(new Subject("subject-a", "과목A", "A101", "A", 3));
|
||||
subjectB = subjectRepository.save(new Subject("subject-b", "과목B", "B101", "B", 3));
|
||||
user = userRepository.save(new User("user", "학생", 2L, UserRole.STUDENT));
|
||||
lessonA = lessonRepository.save(new Lesson("lesson-a", "강의A", subjectA, professor, semester, 30, null, null));
|
||||
lessonB = lessonRepository.save(new Lesson("lesson-b", "강의B", subjectB, professor, semester, 30, null, null));
|
||||
lessonScheduleRepository.save(new LessonSchedule("schedule-a", lessonA, DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(11, 0)));
|
||||
lessonScheduleRepository.save(new LessonSchedule("schedule-b", lessonB, DayOfWeek.TUESDAY, LocalTime.of(9, 0), LocalTime.of(11, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerCreatesActiveRegistrationAndReturnsTotalCredits() {
|
||||
CourseRegistrationResponse response = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
|
||||
|
||||
assertThat(response.userId()).isEqualTo(user.getId());
|
||||
assertThat(response.lessonId()).isEqualTo(lessonA.getId());
|
||||
assertThat(response.totalCredits()).isEqualTo(3);
|
||||
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lessonA.getId())).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateRegistrationIsRejected() {
|
||||
service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
|
||||
|
||||
assertThatThrownBy(() -> service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId())))
|
||||
.isInstanceOfSatisfying(RegistrationException.class,
|
||||
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.ALREADY_REGISTERED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelThenReregisterKeepsHistoryAndOneActiveRow() {
|
||||
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
|
||||
service.cancel(user.getId(), first.registrationId());
|
||||
|
||||
CourseRegistrationResponse second = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
|
||||
|
||||
assertThat(second.registrationId()).isNotEqualTo(first.registrationId());
|
||||
assertThat(userLessonRepository.findAll()).hasSize(2);
|
||||
assertThat(userLessonRepository.findAll().stream().filter(it -> it.getCanceledAt() == null)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherUserCannotCancelRegistration() {
|
||||
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
|
||||
User other = userRepository.save(new User("other-user", "다른학생", 2L, UserRole.STUDENT));
|
||||
|
||||
assertThatThrownBy(() -> service.cancel(other.getId(), first.registrationId()))
|
||||
.isInstanceOfSatisfying(RegistrationException.class,
|
||||
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.REGISTRATION_FORBIDDEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelingAlreadyCanceledRegistrationIsRejected() {
|
||||
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
|
||||
service.cancel(user.getId(), first.registrationId());
|
||||
|
||||
assertThatThrownBy(() -> service.cancel(user.getId(), first.registrationId()))
|
||||
.isInstanceOfSatisfying(RegistrationException.class,
|
||||
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.ALREADY_CANCELED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelingFreesCapacityForNextRegistration() {
|
||||
Lesson capacityOne = lessonRepository.save(new Lesson("capacity-one", "정원1", subjectB, professor, semester, 1, null, null));
|
||||
lessonScheduleRepository.save(new LessonSchedule("capacity-one-schedule", capacityOne, DayOfWeek.WEDNESDAY, LocalTime.of(13, 0), LocalTime.of(15, 0)));
|
||||
User other = userRepository.save(new User("capacity-other", "다른학생", 2L, UserRole.STUDENT));
|
||||
|
||||
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(capacityOne.getId()));
|
||||
service.cancel(user.getId(), first.registrationId());
|
||||
CourseRegistrationResponse second = service.register(other.getId(), new CourseRegistrationRequest(capacityOne.getId()));
|
||||
|
||||
assertThat(second.userId()).isEqualTo(other.getId());
|
||||
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(capacityOne.getId())).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationOutsideRegistrationPeriodIsRejected() {
|
||||
CourseRegistrationResponse registration = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
|
||||
Semester closedSemester = semesterRepository.save(new Semester("closed-semester", "closed", LocalDate.now().minusDays(100),
|
||||
LocalDate.now().plusDays(100), Instant.now().minusSeconds(7200), Instant.now().minusSeconds(3600), 18));
|
||||
Lesson closedLesson = lessonRepository.save(new Lesson("closed-lesson", "마감강의", subjectB, professor, closedSemester, 30, null, null));
|
||||
lessonScheduleRepository.save(new LessonSchedule("closed-schedule", closedLesson, DayOfWeek.THURSDAY, LocalTime.of(9, 0), LocalTime.of(11, 0)));
|
||||
var closedRegistration = userLessonRepository.saveAndFlush(new com.study.course_registration.entity.UserLesson(user, closedLesson));
|
||||
|
||||
assertThatThrownBy(() -> service.cancel(user.getId(), closedRegistration.getId()))
|
||||
.isInstanceOfSatisfying(RegistrationException.class,
|
||||
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.REGISTRATION_PERIOD_CLOSED));
|
||||
|
||||
assertThat(userLessonRepository.findById(registration.registrationId())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeRegistrationListOmitsCanceledRows() {
|
||||
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
|
||||
service.cancel(user.getId(), first.registrationId());
|
||||
service.register(user.getId(), new CourseRegistrationRequest(lessonB.getId()));
|
||||
|
||||
var response = service.getRegistrations(user.getId(), semester.getId());
|
||||
|
||||
assertThat(response.totalCredits()).isEqualTo(3);
|
||||
assertThat(response.registrations()).extracting(it -> it.lessonId()).containsExactly(lessonB.getId());
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.study.course_registration.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
import com.study.course_registration.entity.Professor;
|
||||
import com.study.course_registration.entity.Semester;
|
||||
import com.study.course_registration.entity.Subject;
|
||||
import com.study.course_registration.entity.User;
|
||||
import com.study.course_registration.enums.RegistrationErrorCode;
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
import com.study.course_registration.exception.RegistrationException;
|
||||
import com.study.course_registration.repository.*;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class LessonQueryServiceIntegrationTest {
|
||||
@Autowired LessonQueryService lessonQueryService;
|
||||
@Autowired CourseRegistrationService registrationService;
|
||||
@Autowired UserLessonRepository userLessonRepository;
|
||||
@Autowired LessonScheduleRepository lessonScheduleRepository;
|
||||
@Autowired LessonRepository lessonRepository;
|
||||
@Autowired SubjectRepository subjectRepository;
|
||||
@Autowired ProfessorRepository professorRepository;
|
||||
@Autowired SemesterRepository semesterRepository;
|
||||
@Autowired UserRepository userRepository;
|
||||
|
||||
private Semester semester;
|
||||
private Lesson lesson;
|
||||
private User user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userLessonRepository.deleteAll();
|
||||
lessonScheduleRepository.deleteAll();
|
||||
lessonRepository.deleteAll();
|
||||
subjectRepository.deleteAll();
|
||||
professorRepository.deleteAll();
|
||||
semesterRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
|
||||
Instant now = Instant.now();
|
||||
semester = semesterRepository.save(new Semester("semester-query", "2026-1", LocalDate.now().minusDays(1),
|
||||
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), 18));
|
||||
Professor professor = professorRepository.save(new Professor("prof-query", "조회교수"));
|
||||
Subject subject = subjectRepository.save(new Subject("subject-query", "자료구조", "CS202", "자료구조 설명", 3));
|
||||
lesson = lessonRepository.save(new Lesson("lesson-query", "자료구조 01", subject, professor, semester, 30, null, null));
|
||||
lessonScheduleRepository.save(new LessonSchedule("schedule-query", lesson, DayOfWeek.MONDAY,
|
||||
LocalTime.of(9, 0), LocalTime.of(11, 0)));
|
||||
user = userRepository.save(new User("user-query", "조회학생", 2L, UserRole.STUDENT));
|
||||
registrationService.register(user.getId(), new CourseRegistrationRequest(lesson.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsesCurrentSemesterAndReturnsBulkDerivedFields() {
|
||||
var response = lessonQueryService.getLessons(null, null, 0, 20);
|
||||
|
||||
assertThat(response.totalElements()).isEqualTo(1);
|
||||
assertThat(response.lessons()).singleElement().satisfies(item -> {
|
||||
assertThat(item.lessonId()).isEqualTo(lesson.getId());
|
||||
assertThat(item.enrolledCount()).isEqualTo(1);
|
||||
assertThat(item.schedules()).hasSize(1);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailContainsSubjectAndSemesterRegistrationMetadata() {
|
||||
var response = lessonQueryService.getLesson(lesson.getId());
|
||||
|
||||
assertThat(response.subjectDescription()).isEqualTo("자료구조 설명");
|
||||
assertThat(response.semesterName()).isEqualTo(semester.getName());
|
||||
assertThat(response.enrolledCount()).isEqualTo(1);
|
||||
assertThat(response.registrationStartAt().toEpochMilli()).isEqualTo(semester.getRegistrationStartAt().toEpochMilli());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSemesterIsRejected() {
|
||||
assertThatThrownBy(() -> lessonQueryService.getLessons("missing", null, 0, 20))
|
||||
.isInstanceOfSatisfying(RegistrationException.class,
|
||||
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.SEMESTER_NOT_FOUND));
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.study.course_registration.service.policy;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
import com.study.course_registration.entity.Semester;
|
||||
import com.study.course_registration.entity.Subject;
|
||||
import com.study.course_registration.entity.User;
|
||||
import com.study.course_registration.entity.UserLesson;
|
||||
import com.study.course_registration.enums.RegistrationErrorCode;
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
import com.study.course_registration.exception.RegistrationException;
|
||||
|
||||
class RegistrationValidatorTest {
|
||||
private final RegistrationValidator validator = new RegistrationValidator(new ScheduleConflictChecker());
|
||||
|
||||
@Test
|
||||
void registrationEndAtIsInclusive() {
|
||||
Semester semester = TestFixtures.semester(18);
|
||||
User user = TestFixtures.student("user", 1);
|
||||
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3), semester, 30, null, null);
|
||||
|
||||
assertThatCode(() -> validator.validatePreconditions(user, lesson, semester.getRegistrationEndAt()))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsClosedRegistrationPeriodBeforeEligibilityChecks() {
|
||||
User user = TestFixtures.student("user", 1);
|
||||
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
|
||||
TestFixtures.closedSemester(), 30, 3L, UserRole.POSTGRADUATE);
|
||||
|
||||
assertError(RegistrationErrorCode.REGISTRATION_PERIOD_CLOSED,
|
||||
() -> validator.validatePreconditions(user, lesson, TestFixtures.NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsGradeBelowMinimum() {
|
||||
User user = TestFixtures.student("user", 1);
|
||||
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
|
||||
TestFixtures.semester(18), 30, 3L, null);
|
||||
|
||||
assertError(RegistrationErrorCode.NOT_ELIGIBLE_GRADE,
|
||||
() -> validator.validatePreconditions(user, lesson, TestFixtures.NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRoleMismatch() {
|
||||
User user = TestFixtures.student("user", 3);
|
||||
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
|
||||
TestFixtures.semester(18), 30, null, UserRole.POSTGRADUATE);
|
||||
|
||||
assertError(RegistrationErrorCode.NOT_ELIGIBLE_ROLE,
|
||||
() -> validator.validatePreconditions(user, lesson, TestFixtures.NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateLessonTakesPrecedenceOverDuplicateSubject() {
|
||||
Semester semester = TestFixtures.semester(18);
|
||||
Subject subject = TestFixtures.subject("subject", 3);
|
||||
User user = TestFixtures.student("user", 2);
|
||||
Lesson lesson = TestFixtures.lesson("lesson", subject, semester, 30, null, null);
|
||||
UserLesson existing = TestFixtures.registration("reg", user, lesson);
|
||||
|
||||
assertError(RegistrationErrorCode.ALREADY_REGISTERED,
|
||||
() -> validator.validateLocked(lesson, List.of(existing), Map.of(), 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDifferentSectionOfSameSubject() {
|
||||
Semester semester = TestFixtures.semester(18);
|
||||
Subject subject = TestFixtures.subject("subject", 3);
|
||||
User user = TestFixtures.student("user", 2);
|
||||
Lesson existingLesson = TestFixtures.lesson("lesson-a", subject, semester, 30, null, null);
|
||||
Lesson newLesson = TestFixtures.lesson("lesson-b", subject, semester, 30, null, null);
|
||||
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
|
||||
|
||||
assertError(RegistrationErrorCode.DUPLICATE_SUBJECT,
|
||||
() -> validator.validateLocked(newLesson, List.of(existing), Map.of(), 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsScheduleConflict() {
|
||||
Semester semester = TestFixtures.semester(18);
|
||||
User user = TestFixtures.student("user", 2);
|
||||
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
|
||||
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
|
||||
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
|
||||
LessonSchedule existingSchedule = TestFixtures.schedule("schedule-a", existingLesson, DayOfWeek.MONDAY, 9, 11);
|
||||
LessonSchedule newSchedule = TestFixtures.schedule("schedule-b", newLesson, DayOfWeek.MONDAY, 10, 12);
|
||||
|
||||
assertError(RegistrationErrorCode.SCHEDULE_CONFLICT,
|
||||
() -> validator.validateLocked(newLesson, List.of(existing),
|
||||
Map.of(existingLesson.getId(), List.of(existingSchedule), newLesson.getId(), List.of(newSchedule)), 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scheduleBoundaryIsAllowed() {
|
||||
Semester semester = TestFixtures.semester(18);
|
||||
User user = TestFixtures.student("user", 2);
|
||||
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
|
||||
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
|
||||
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
|
||||
LessonSchedule existingSchedule = TestFixtures.schedule("schedule-a", existingLesson, DayOfWeek.MONDAY, 9, 11);
|
||||
LessonSchedule newSchedule = TestFixtures.schedule("schedule-b", newLesson, DayOfWeek.MONDAY, 11, 13);
|
||||
|
||||
assertThatCode(() -> validator.validateLocked(newLesson, List.of(existing),
|
||||
Map.of(existingLesson.getId(), List.of(existingSchedule), newLesson.getId(), List.of(newSchedule)), 1))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactCreditLimitIsAllowed() {
|
||||
Semester semester = TestFixtures.semester(6);
|
||||
User user = TestFixtures.student("user", 2);
|
||||
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
|
||||
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
|
||||
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
|
||||
|
||||
assertThatCode(() -> validator.validateLocked(newLesson, List.of(existing), Map.of(), 1))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(validator.totalCreditsAfter(newLesson, List.of(existing))).isEqualTo(6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCreditAboveLimit() {
|
||||
Semester semester = TestFixtures.semester(5);
|
||||
User user = TestFixtures.student("user", 2);
|
||||
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
|
||||
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
|
||||
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
|
||||
|
||||
assertError(RegistrationErrorCode.CREDIT_LIMIT_EXCEEDED,
|
||||
() -> validator.validateLocked(newLesson, List.of(existing), Map.of(), 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastCapacitySlotIsAllowedButFullClassIsRejected() {
|
||||
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
|
||||
TestFixtures.semester(18), 30, null, null);
|
||||
|
||||
assertThatCode(() -> validator.validateLocked(lesson, List.of(), Map.of(), 29))
|
||||
.doesNotThrowAnyException();
|
||||
assertError(RegistrationErrorCode.CAPACITY_EXCEEDED,
|
||||
() -> validator.validateLocked(lesson, List.of(), Map.of(), 30));
|
||||
}
|
||||
|
||||
private static void assertError(RegistrationErrorCode code, Runnable action) {
|
||||
assertThatThrownBy(action::run)
|
||||
.isInstanceOfSatisfying(RegistrationException.class,
|
||||
ex -> assertThat(ex.getErrorCode()).isEqualTo(code));
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.study.course_registration.service.policy;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalTime;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
|
||||
class ScheduleConflictCheckerTest {
|
||||
private final ScheduleConflictChecker checker = new ScheduleConflictChecker();
|
||||
|
||||
@Test
|
||||
void overlappingTimesOnSameDayConflict() {
|
||||
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
|
||||
LessonSchedule b = TestFixtures.schedule(DayOfWeek.MONDAY, 10, 12);
|
||||
|
||||
assertThat(checker.overlaps(a, b)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void touchingBoundaryDoesNotConflict() {
|
||||
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
|
||||
LessonSchedule b = TestFixtures.schedule(DayOfWeek.MONDAY, 11, 13);
|
||||
|
||||
assertThat(checker.overlaps(a, b)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void separatedTimesOnSameDayDoNotConflict() {
|
||||
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
|
||||
LessonSchedule b = TestFixtures.schedule(DayOfWeek.MONDAY, 13, 15);
|
||||
|
||||
assertThat(checker.overlaps(a, b)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameTimeOnDifferentDayDoesNotConflict() {
|
||||
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
|
||||
LessonSchedule b = TestFixtures.schedule(DayOfWeek.TUESDAY, 9, 11);
|
||||
|
||||
assertThat(checker.overlaps(a, b)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.study.course_registration.service.policy;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
|
||||
import com.study.course_registration.entity.Lesson;
|
||||
import com.study.course_registration.entity.LessonSchedule;
|
||||
import com.study.course_registration.entity.Professor;
|
||||
import com.study.course_registration.entity.Semester;
|
||||
import com.study.course_registration.entity.Subject;
|
||||
import com.study.course_registration.entity.User;
|
||||
import com.study.course_registration.entity.UserLesson;
|
||||
import com.study.course_registration.enums.UserRole;
|
||||
|
||||
final class TestFixtures {
|
||||
static final Instant NOW = Instant.parse("2026-09-17T10:00:00Z");
|
||||
|
||||
private TestFixtures() {
|
||||
}
|
||||
|
||||
static Semester semester(int maxCredits) {
|
||||
return new Semester("semester-1", "2026-1", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 12, 31),
|
||||
NOW.minusSeconds(3600), NOW.plusSeconds(3600), maxCredits);
|
||||
}
|
||||
|
||||
static Semester closedSemester() {
|
||||
return new Semester("semester-closed", "closed", LocalDate.of(2026, 1, 1), LocalDate.of(2026, 2, 1),
|
||||
NOW.minusSeconds(7200), NOW.minusSeconds(3600), 18);
|
||||
}
|
||||
|
||||
static User student(String id, long grade) {
|
||||
return new User(id, id, grade, UserRole.STUDENT);
|
||||
}
|
||||
|
||||
static User postgraduate(String id) {
|
||||
return new User(id, id, 3L, UserRole.POSTGRADUATE);
|
||||
}
|
||||
|
||||
static Lesson lesson(String id, Subject subject, Semester semester, int capacity, Long minGrade, UserRole role) {
|
||||
return new Lesson(id, id, subject, new Professor("prof-" + id, "prof"), semester, capacity, minGrade, role);
|
||||
}
|
||||
|
||||
static Subject subject(String id, int credit) {
|
||||
return new Subject(id, id, id, id, credit);
|
||||
}
|
||||
|
||||
static UserLesson registration(String id, User user, Lesson lesson) {
|
||||
return new UserLesson(id, user, lesson);
|
||||
}
|
||||
|
||||
static LessonSchedule schedule(DayOfWeek day, int startHour, int endHour) {
|
||||
Subject subject = subject("subject-" + day + startHour, 3);
|
||||
Lesson lesson = lesson("lesson-" + day + startHour, subject, semester(18), 30, null, null);
|
||||
return new LessonSchedule("schedule-" + day + startHour, lesson, day,
|
||||
LocalTime.of(startHour, 0), LocalTime.of(endHour, 0));
|
||||
}
|
||||
|
||||
static LessonSchedule schedule(String id, Lesson lesson, DayOfWeek day, int startHour, int endHour) {
|
||||
return new LessonSchedule(id, lesson, day, LocalTime.of(startHour, 0), LocalTime.of(endHour, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:course_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;LOCK_TIMEOUT=3000
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
open-in-view: false
|
||||
h2:
|
||||
console:
|
||||
enabled: false
|
||||
app:
|
||||
seed:
|
||||
enabled: false
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
Reference in New Issue
Block a user