feat : 수강신청 도메인 구성

This commit is contained in:
donghyeon-ka
2026-09-18 15:20:54 +09:00
parent 3e33388e02
commit 70d71008c6
61 changed files with 2835 additions and 258 deletions
@@ -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"));
}
}
@@ -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) {
}
@@ -0,0 +1,7 @@
package com.study.course_registration.dto.registration;
import jakarta.validation.constraints.NotBlank;
public record CourseRegistrationRequest(
@NotBlank(message = "lessonId는 필수입니다.") String lessonId) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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());
}
}