feat : 수강신청 도메인 구성
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.gradle
|
||||||
|
build
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
SPRING_PROFILES_ACTIVE=dev
|
||||||
|
SERVER_ADDRESS=127.0.0.1
|
||||||
|
SERVER_PORT=8080
|
||||||
|
DB_HOST=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=course_registration
|
||||||
|
DB_USERNAME=course
|
||||||
|
DB_PASSWORD=change-me
|
||||||
|
APP_SEED_ENABLED=true
|
||||||
|
SWAGGER_ENABLED=true
|
||||||
@@ -35,3 +35,9 @@ out/
|
|||||||
|
|
||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
### Local environment ###
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
FROM eclipse-temurin:21-jdk-jammy AS builder
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY gradlew gradlew
|
||||||
|
COPY gradle gradle
|
||||||
|
COPY build.gradle settings.gradle ./
|
||||||
|
RUN chmod +x gradlew
|
||||||
|
COPY src src
|
||||||
|
RUN ./gradlew bootJar --no-daemon
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jre-jammy
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /workspace/build/libs/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
@@ -1,2 +1,146 @@
|
|||||||
# course-registration
|
# course-registration
|
||||||
|
|
||||||
|
Spring Boot 4.1.1 / Java 21 기반 수강신청 API 예제입니다. 핵심은 CRUD 개수보다 **수강신청 거부 규칙(R1~R8)** 과 **동시 신청 시 정원·학점·시간표 정확성**을 지키는 것입니다.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 수강신청(Create)
|
||||||
|
- 내 수강신청 목록 / 강의 목록·상세(Read)
|
||||||
|
- 수강취소(Delete, soft cancel)
|
||||||
|
- 신청 수정(Update) API 없음 — 취소 후 재신청
|
||||||
|
- 마스터 데이터 CRUD 없음 — Seeder 사용
|
||||||
|
- 인증/JWT 없음
|
||||||
|
- k6 부하 테스트는 후속 범위
|
||||||
|
|
||||||
|
상세 규칙은 [`docs/Requirements.md`](docs/Requirements.md)를 기준으로 합니다.
|
||||||
|
|
||||||
|
## Run local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
기본 profile은 `local`이며 H2 + Seeder가 자동 활성화됩니다.
|
||||||
|
|
||||||
|
- API: `http://127.0.0.1:8080/api/v1`
|
||||||
|
- Swagger UI: `http://127.0.0.1:8080/swagger-ui.html`
|
||||||
|
- OpenAPI JSON: `http://127.0.0.1:8080/v3/api-docs`
|
||||||
|
- Health: `http://127.0.0.1:8080/actuator/health`
|
||||||
|
- H2 console: `http://127.0.0.1:8080/h2-console`
|
||||||
|
|
||||||
|
## Run dev with PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# 필요한 값 수정
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose는 host에 `127.0.0.1`로만 노출합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
# DB volume까지 초기화하려면
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
| profile | DB | ddl-auto | Seeder | Swagger |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| local | H2 | create-drop | ON | ON |
|
||||||
|
| dev | PostgreSQL | update | `APP_SEED_ENABLED` | ON |
|
||||||
|
| prod | PostgreSQL | validate | OFF | `SWAGGER_ENABLED` 기본 false |
|
||||||
|
|
||||||
|
운영 profile의 DB 접속 정보는 반드시 환경변수로 주입합니다.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/users/{userId}/registrations
|
||||||
|
GET /api/v1/users/{userId}/registrations?semesterId={semesterId}
|
||||||
|
DELETE /api/v1/users/{userId}/registrations/{registrationId}
|
||||||
|
GET /api/v1/lessons?semesterId={id}&subjectId={id}&page=0&size=20
|
||||||
|
GET /api/v1/lessons/{lessonId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concurrency invariant
|
||||||
|
|
||||||
|
모든 registration mutation은 아래 순서로 row lock을 획득합니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
User FOR UPDATE
|
||||||
|
-> Lesson FOR UPDATE
|
||||||
|
-> R4~R8 검증
|
||||||
|
-> INSERT / cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
User lock은 동일 학생의 학점·동일과목·시간표 경쟁을 직렬화하고, Lesson lock은 정원 경쟁을 직렬화합니다. 락 순서는 항상 `User -> Lesson`으로 고정합니다.
|
||||||
|
|
||||||
|
## Local seed IDs
|
||||||
|
|
||||||
|
Postman과 동일한 고정 ID를 사용합니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Semester
|
||||||
|
00000000-0000-0000-0000-000000000001
|
||||||
|
|
||||||
|
1학년 학생
|
||||||
|
30000000-0000-0000-0000-000000000001
|
||||||
|
|
||||||
|
2학년 학생
|
||||||
|
30000000-0000-0000-0000-000000000002
|
||||||
|
|
||||||
|
대학원생
|
||||||
|
30000000-0000-0000-0000-000000000003
|
||||||
|
|
||||||
|
자료구조 01분반
|
||||||
|
40000000-0000-0000-0000-000000000001
|
||||||
|
|
||||||
|
자료구조 02분반 (동일 Subject)
|
||||||
|
40000000-0000-0000-0000-000000000002
|
||||||
|
|
||||||
|
운영체제 (자료구조 01과 월요일 시간 충돌)
|
||||||
|
40000000-0000-0000-0000-000000000003
|
||||||
|
|
||||||
|
정원 1명 강의
|
||||||
|
40000000-0000-0000-0000-000000000004
|
||||||
|
|
||||||
|
최소 3학년 강의
|
||||||
|
40000000-0000-0000-0000-000000000005
|
||||||
|
|
||||||
|
대학원생 전용 강의
|
||||||
|
40000000-0000-0000-0000-000000000006
|
||||||
|
|
||||||
|
월요일 11:00~13:00 경계 테스트 강의
|
||||||
|
40000000-0000-0000-0000-000000000007
|
||||||
|
```
|
||||||
|
|
||||||
|
## Postman
|
||||||
|
|
||||||
|
```text
|
||||||
|
postman/
|
||||||
|
course-registration.postman_collection.json
|
||||||
|
local.postman_environment.json
|
||||||
|
dev.postman_environment.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`Register lesson` 요청은 성공 시 응답의 `registrationId`를 environment에 자동 저장합니다.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test
|
||||||
|
./gradlew build
|
||||||
|
```
|
||||||
|
|
||||||
|
테스트에는 다음이 포함됩니다.
|
||||||
|
|
||||||
|
- R1~R8 규칙 및 경계값
|
||||||
|
- 신청/조회/취소/재신청
|
||||||
|
- 취소 이력 유지
|
||||||
|
- 정원 1명에 동시 10명
|
||||||
|
- 정원 30명에 동시 50명
|
||||||
|
- 동일 학생의 학점 한도 경쟁
|
||||||
|
- 동일 학생의 시간표 충돌 경쟁
|
||||||
|
- 동일 학생의 같은 강의 중복 경쟁
|
||||||
|
|||||||
+3
-1
@@ -28,9 +28,11 @@ dependencies {
|
|||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
//Swagger
|
//Swagger
|
||||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1'
|
||||||
//Validation
|
//Validation
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
// Actuator
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
//Jackson
|
//Jackson
|
||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||||
//H2
|
//H2
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME:-course_registration}
|
||||||
|
POSTGRES_USER: ${DB_USERNAME:-course}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-course}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${DB_EXPOSE_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-course} -d ${DB_NAME:-course_registration}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: dev
|
||||||
|
SERVER_ADDRESS: 0.0.0.0
|
||||||
|
SERVER_PORT: 8080
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: ${DB_NAME:-course_registration}
|
||||||
|
DB_USERNAME: ${DB_USERNAME:-course}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-course}
|
||||||
|
APP_SEED_ENABLED: ${APP_SEED_ENABLED:-true}
|
||||||
|
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-true}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/actuator/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Course Registration Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Implement task-by-task with test-first verification for behavior changes.
|
||||||
|
|
||||||
|
**Goal:** Implement the Requirements.md course-registration API, concurrency rules, query APIs, seed data, OpenAPI/Postman assets, environment profiles, and Docker Compose while leaving k6 for a later phase.
|
||||||
|
|
||||||
|
**Architecture:** Keep transaction/lock orchestration in application services, isolate deterministic registration rules in a validator/schedule checker, use JPA repositories for fixed User -> Lesson pessimistic locking, and separate lesson query concerns from registration mutation concerns. Use bulk schedule/enrollment queries to avoid per-row N+1 behavior.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot 4.1.1, Spring Data JPA, H2 local/test, PostgreSQL dev/prod, springdoc-openapi 3.x, JUnit 5.
|
||||||
|
|
||||||
|
**Spec:** `docs/Requirements.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- No master-data CRUD APIs.
|
||||||
|
- No update-registration API; section changes are cancel + re-register.
|
||||||
|
- No authentication/JWT in this scope.
|
||||||
|
- Lock order is always User -> Lesson.
|
||||||
|
- Registration rejection order is R1 through R8 exactly as specified.
|
||||||
|
- Cancellation rejection order is C1 through C4.
|
||||||
|
- k6 is explicitly deferred.
|
||||||
|
- Existing tracked `.env` is not read, printed, or rewritten; provide `.env.example` separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Normalize persistence model and restore a compilable baseline
|
||||||
|
- Modify BaseEntity/BaseCreateEntity/BaseTimeZoneEntity, Semester, Subject, Lesson, LessonSchedule, UserLesson.
|
||||||
|
- Remove composite UserLessonId usage and use String UUID surrogate IDs.
|
||||||
|
- Add domain integrity checks for capacity, credit, schedule range, and semester ranges.
|
||||||
|
- Add repository types needed by the new model.
|
||||||
|
- Verify `compileJava` before adding behavior.
|
||||||
|
|
||||||
|
### Task 2: Add registration rule tests and implementation
|
||||||
|
- Add tests for period boundaries, grade/role, duplicate lesson/subject, schedule overlap boundaries, credit boundary, and capacity boundary.
|
||||||
|
- Implement RegistrationErrorCode, RegistrationException, ScheduleConflictChecker, and RegistrationValidator.
|
||||||
|
- Run targeted unit tests red -> green.
|
||||||
|
|
||||||
|
### Task 3: Implement transaction and locking flow
|
||||||
|
- Add User and Lesson pessimistic lock repository methods with 3s lock hints.
|
||||||
|
- Implement CourseRegistrationService register/cancel/list using fixed User -> Lesson lock ordering.
|
||||||
|
- Add integration tests for success/cancel/re-register and core rejection paths.
|
||||||
|
- Add concurrency tests for capacity, same-student credit, schedule, and duplicate requests.
|
||||||
|
|
||||||
|
### Task 4: Implement HTTP API and lesson queries
|
||||||
|
- Add typed DTO records and validation.
|
||||||
|
- Add registration controller POST/GET/DELETE endpoints.
|
||||||
|
- Add LessonQueryService and LessonController list/detail endpoints.
|
||||||
|
- Bulk load schedules and enrollment counts for paged lesson results.
|
||||||
|
- Add common error response and controller advice.
|
||||||
|
|
||||||
|
### Task 5: Seed data and runtime profiles
|
||||||
|
- Replace DataSeeder with deterministic, idempotent scenario-oriented local/dev seed data controlled by `app.seed.enabled`.
|
||||||
|
- Split shared/local/dev/prod config; local H2, dev PostgreSQL update, prod PostgreSQL validate.
|
||||||
|
- Provide Clock bean for deterministic service tests.
|
||||||
|
|
||||||
|
### Task 6: Swagger, Postman, Docker
|
||||||
|
- Upgrade springdoc to Boot-4-compatible 3.x and configure OpenAPI metadata.
|
||||||
|
- Add Postman collection plus local/dev environments using deterministic seed IDs.
|
||||||
|
- Add Actuator health, Dockerfile, `.dockerignore`, `.env.example`, and docker-compose with PostgreSQL + app health checks.
|
||||||
|
|
||||||
|
### Task 7: Final verification
|
||||||
|
- Run full `./gradlew test` and `./gradlew build` as managed tasks.
|
||||||
|
- Validate Docker Compose config when Docker Compose is available.
|
||||||
|
- Inspect git diff/status and report implemented scope and any remaining environmental limitations.
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"name": "Course Registration API",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||||
|
"description": "Seed dataset 기반 수강신청 API 검증 컬렉션. k6 부하 테스트는 별도 후속 범위입니다."
|
||||||
|
},
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Lessons",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "List lessons",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/lessons?semesterId={{semesterId}}&page=0&size=20",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get lesson",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/lessons/{{lessonId}}",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Registrations",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Register lesson",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\"lessonId\": \"{{lessonId}}\"}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test('201 Created', () => pm.response.to.have.status(201));",
|
||||||
|
"const body = pm.response.json();",
|
||||||
|
"pm.environment.set('registrationId', body.registrationId);"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "My registrations",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations?semesterId={{semesterId}}",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cancel registration",
|
||||||
|
"request": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations/{{registrationId}}",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test('204 No Content', () => pm.response.to.have.status(204));"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Error Scenarios",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Grade restriction",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\"lessonId\": \"{{minGradeLessonId}}\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Role restriction",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\"lessonId\": \"{{postgraduateOnlyLessonId}}\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Capacity first seat",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\"lessonId\": \"{{capacityOneLessonId}}\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Capacity exceeded",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\"lessonId\": \"{{capacityOneLessonId}}\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Duplicate registration first",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\"lessonId\": \"{{dataStructures01Id}}\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Duplicate registration again",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\"lessonId\": \"{{dataStructures01Id}}\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Duplicate subject other section",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\"lessonId\": \"{{dataStructures02Id}}\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Schedule conflict",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\"lessonId\": \"{{overlapLessonId}}\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "course-registration-dev",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"key": "baseUrl",
|
||||||
|
"value": "http://127.0.0.1:8080",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "semesterId",
|
||||||
|
"value": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "studentId",
|
||||||
|
"value": "30000000-0000-0000-0000-000000000001",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "student2Id",
|
||||||
|
"value": "30000000-0000-0000-0000-000000000002",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "postgraduateId",
|
||||||
|
"value": "30000000-0000-0000-0000-000000000003",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "lessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000001",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dataStructures01Id",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000001",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dataStructures02Id",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000002",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "overlapLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000003",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "capacityOneLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000004",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "minGradeLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000005",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "postgraduateOnlyLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000006",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "boundaryLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000007",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "registrationId",
|
||||||
|
"value": "",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_postman_variable_scope": "environment"
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "course-registration-local",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"key": "baseUrl",
|
||||||
|
"value": "http://127.0.0.1:8080",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "semesterId",
|
||||||
|
"value": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "studentId",
|
||||||
|
"value": "30000000-0000-0000-0000-000000000001",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "student2Id",
|
||||||
|
"value": "30000000-0000-0000-0000-000000000002",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "postgraduateId",
|
||||||
|
"value": "30000000-0000-0000-0000-000000000003",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "lessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000001",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dataStructures01Id",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000001",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dataStructures02Id",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000002",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "overlapLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000003",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "capacityOneLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000004",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "minGradeLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000005",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "postgraduateOnlyLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000006",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "boundaryLessonId",
|
||||||
|
"value": "40000000-0000-0000-0000-000000000007",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "registrationId",
|
||||||
|
"value": "",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_postman_variable_scope": "environment"
|
||||||
|
}
|
||||||
@@ -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;
|
package com.study.course_registration.controller;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import org.springframework.http.HttpStatus;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
|
||||||
|
|
||||||
import org.springframework.validation.annotation.Validated;
|
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.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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 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.service.CourseRegistrationService;
|
||||||
|
|
||||||
import com.study.course_registration.dto.RequestDto;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import com.study.course_registration.dto.ResponseDto;
|
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
|
@RestController
|
||||||
@RequestMapping ("/api/v1")
|
@Validated
|
||||||
|
@RequestMapping("/api/v1/users/{userId}/registrations")
|
||||||
|
@Tag(name = "Registrations", description = "수강신청/조회/취소 API")
|
||||||
public class CourseRegistrationController {
|
public class CourseRegistrationController {
|
||||||
|
|
||||||
private final CourseRegistrationService courseRegistrationService;
|
private final CourseRegistrationService courseRegistrationService;
|
||||||
|
|
||||||
public CourseRegistrationController(CourseRegistrationService courseRegistrationService) {
|
public CourseRegistrationController(CourseRegistrationService courseRegistrationService) {
|
||||||
this.courseRegistrationService = courseRegistrationService;
|
this.courseRegistrationService = courseRegistrationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/course-register")
|
@PostMapping
|
||||||
@Operation(summary = "Register a course",
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
description = "Registers a new course with the provided details.")
|
@Operation(summary = "수강신청")
|
||||||
@ApiResponses({
|
@ApiResponses({
|
||||||
@ApiResponse (responseCode = "200", description = "Course registered successfully"),
|
@ApiResponse(responseCode = "201", description = "신청 성공"),
|
||||||
@ApiResponse (responseCode = "400", description = "Invalid request data")
|
@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) {
|
public CourseRegistrationResponse register(
|
||||||
return courseRegistrationService.registerCourse(requestDto);
|
@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.annotation.CreatedDate;
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
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
|
@Getter
|
||||||
@EntityListeners (AuditingEntityListener.class)
|
@MappedSuperclass
|
||||||
public class BaseCreateEntity {
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
@CreatedDate
|
public abstract class BaseCreateEntity extends BaseEntity {
|
||||||
|
@CreatedDate
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
|
protected BaseCreateEntity() {
|
||||||
|
}
|
||||||
|
|
||||||
|
protected BaseCreateEntity(String id) {
|
||||||
|
super(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
package com.study.course_registration.entity;
|
package com.study.course_registration.entity;
|
||||||
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
import java.util.UUID;
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.MappedSuperclass;
|
import jakarta.persistence.MappedSuperclass;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@MappedSuperclass
|
@MappedSuperclass
|
||||||
public class BaseEntity {
|
public abstract class BaseEntity {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
@Column(length = 36, nullable = false, updatable = false)
|
||||||
private String id;
|
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.CreatedDate;
|
||||||
import org.springframework.data.annotation.LastModifiedDate;
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.EntityListeners;
|
import jakarta.persistence.EntityListeners;
|
||||||
import jakarta.persistence.MappedSuperclass;
|
import jakarta.persistence.MappedSuperclass;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
@MappedSuperclass
|
@Getter
|
||||||
@EntityListeners (AuditingEntityListener.class)
|
@MappedSuperclass
|
||||||
public class BaseTimeZoneEntity extends BaseEntity {
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
@CreatedDate
|
public abstract class BaseTimeZoneEntity extends BaseEntity {
|
||||||
|
@CreatedDate
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@LastModifiedDate
|
@LastModifiedDate
|
||||||
|
@Column(nullable = false)
|
||||||
private Instant updatedAt;
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
protected BaseTimeZoneEntity() {
|
||||||
|
}
|
||||||
|
|
||||||
|
protected BaseTimeZoneEntity(String id) {
|
||||||
|
super(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,74 @@
|
|||||||
package com.study.course_registration.entity;
|
package com.study.course_registration.entity;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import java.util.Objects;
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
import com.study.course_registration.enums.UserRole;
|
import com.study.course_registration.enums.UserRole;
|
||||||
|
|
||||||
@Entity
|
import jakarta.persistence.Column;
|
||||||
@NoArgsConstructor
|
import jakarta.persistence.Entity;
|
||||||
@Getter
|
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")
|
@Table(name = "lessons")
|
||||||
public class Lesson extends BaseTimeZoneEntity {
|
public class Lesson extends BaseTimeZoneEntity {
|
||||||
|
@Column(nullable = false)
|
||||||
private String name;
|
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;
|
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 Professor professor;
|
||||||
|
|
||||||
private Instant startTime;
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "semester_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_semester"))
|
||||||
private Instant endTime;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@Column (name = "semester_id", nullable = false)
|
|
||||||
private Semester semester;
|
private Semester semester;
|
||||||
|
|
||||||
@Column (name = "capacity", nullable = false)
|
@Column(nullable = false)
|
||||||
private Integer capacity;
|
private Integer capacity;
|
||||||
|
|
||||||
@Column (name = "min_grade", nullable = false)
|
@Column(name = "min_grade")
|
||||||
private Long minGrade;
|
private Long minGrade;
|
||||||
|
|
||||||
@Column (name = "allowed_role", nullable = false)
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "allowed_role")
|
||||||
private UserRole allowedRole;
|
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.name = name;
|
||||||
this.subject = subject;
|
this.subject = Objects.requireNonNull(subject, "subject");
|
||||||
this.professor = professor;
|
this.professor = Objects.requireNonNull(professor, "professor");
|
||||||
this.startTime = startTime;
|
this.semester = Objects.requireNonNull(semester, "semester");
|
||||||
this.endTime = endTime;
|
|
||||||
this.semester = semester;
|
|
||||||
this.capacity = capacity;
|
this.capacity = capacity;
|
||||||
this.minGrade = minGrade;
|
this.minGrade = minGrade;
|
||||||
this.allowedRole = allowedRole;
|
this.allowedRole = allowedRole;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,51 @@ package com.study.course_registration.entity;
|
|||||||
|
|
||||||
import java.time.DayOfWeek;
|
import java.time.DayOfWeek;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
import jakarta.persistence.FetchType;
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.ForeignKey;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Getter
|
@Getter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
@Table(name = "lesson_schedules")
|
||||||
public class LessonSchedule extends BaseTimeZoneEntity {
|
public class LessonSchedule extends BaseTimeZoneEntity {
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
@Column()
|
@JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_schedule_lesson"))
|
||||||
private Lesson lesson;
|
private Lesson lesson;
|
||||||
|
|
||||||
@Column()
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 16)
|
||||||
private DayOfWeek dayOfWeek;
|
private DayOfWeek dayOfWeek;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
private LocalTime startTime;
|
private LocalTime startTime;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
private LocalTime endTime;
|
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;
|
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.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Getter
|
@Getter
|
||||||
@Table(name = "professors")
|
@Table(name = "professors")
|
||||||
public class Professor extends BaseTimeZoneEntity {
|
public class Professor extends BaseTimeZoneEntity {
|
||||||
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
public Professor(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;
|
this.name = name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,67 @@ package com.study.course_registration.entity;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
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.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@NoArgsConstructor
|
@Getter
|
||||||
@Getter
|
@NoArgsConstructor
|
||||||
|
@Table(name = "semesters", uniqueConstraints = @UniqueConstraint(name = "uk_semester_name", columnNames = "name"))
|
||||||
public class Semester extends BaseTimeZoneEntity {
|
public class Semester extends BaseTimeZoneEntity {
|
||||||
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
private LocalDate startDate;
|
private LocalDate startDate;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
private LocalDate endDate;
|
private LocalDate endDate;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
private Instant registrationStartAt;
|
private Instant registrationStartAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
private Instant registrationEndAt;
|
private Instant registrationEndAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
private Integer maxCredits;
|
private Integer maxCredits;
|
||||||
|
|
||||||
public Semester(String name) {
|
public Semester(String name, LocalDate startDate, LocalDate endDate,
|
||||||
this.name = name;
|
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;
|
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.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Getter
|
@Getter
|
||||||
@Table(name = "subjects")
|
@Table(name = "subjects", uniqueConstraints = @UniqueConstraint(name = "uk_subject_code", columnNames = "code"))
|
||||||
public class Subject extends BaseTimeZoneEntity {
|
public class Subject extends BaseTimeZoneEntity {
|
||||||
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
private String code;
|
private String code;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 1000)
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
public Subject(String name, String code, String description) {
|
@Column(nullable = false)
|
||||||
this.name = name;
|
private Integer credit;
|
||||||
this.code = code;
|
|
||||||
this.description = description;
|
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;
|
package com.study.course_registration.entity;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
import com.study.course_registration.enums.UserRole;
|
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.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Getter
|
@Getter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Table(name = "users")
|
@Table(name = "users")
|
||||||
public class User extends BaseTimeZoneEntity {
|
public class User extends BaseTimeZoneEntity {
|
||||||
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
private Long grade;
|
private Long grade;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
private UserRole role;
|
private UserRole role;
|
||||||
|
|
||||||
public User(String name, Long grade, 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.name = name;
|
||||||
this.grade = grade;
|
this.grade = grade;
|
||||||
this.role = role;
|
this.role = Objects.requireNonNull(role, "role");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,52 @@
|
|||||||
package com.study.course_registration.entity;
|
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.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Getter
|
@Getter
|
||||||
@Table(name = "user_lessons")
|
@Table(name = "user_lessons")
|
||||||
public class UserLesson extends BaseCreateEntity {
|
public class UserLesson extends BaseCreateEntity {
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
@EmbeddedId
|
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_user"))
|
||||||
private UserLessonId id;
|
|
||||||
|
|
||||||
@MapsId("userId")
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn (
|
|
||||||
name = "user_id",
|
|
||||||
foreignKey = @ForeignKey(name = "fk_user_lesson_user_id")
|
|
||||||
)
|
|
||||||
private User user;
|
private User user;
|
||||||
|
|
||||||
@MapsId("lessonId")
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_lesson"))
|
||||||
@JoinColumn (
|
|
||||||
name = "lesson_id",
|
|
||||||
foreignKey = @ForeignKey(name = "fk_user_lesson_lesson_id")
|
|
||||||
)
|
|
||||||
private Lesson lesson;
|
private Lesson lesson;
|
||||||
|
|
||||||
|
@Column(name = "canceled_at")
|
||||||
|
private Instant canceledAt;
|
||||||
|
|
||||||
public UserLesson(User user, Lesson lesson) {
|
public UserLesson(User user, Lesson lesson) {
|
||||||
this.user = user;
|
this(null, user, lesson);
|
||||||
this.lesson = 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;
|
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.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 com.study.course_registration.entity.Lesson;
|
||||||
|
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
import jakarta.persistence.QueryHint;
|
||||||
|
|
||||||
public interface LessonRepository extends JpaRepository<Lesson, String> {
|
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;
|
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.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.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;
|
package com.study.course_registration.repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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 com.study.course_registration.entity.User;
|
||||||
|
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
import jakarta.persistence.QueryHint;
|
||||||
|
|
||||||
public interface UserRepository extends JpaRepository<User, String> {
|
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;
|
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.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import com.study.course_registration.entity.Lesson;
|
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.Professor;
|
||||||
|
import com.study.course_registration.entity.Semester;
|
||||||
import com.study.course_registration.entity.Subject;
|
import com.study.course_registration.entity.Subject;
|
||||||
import com.study.course_registration.entity.User;
|
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.enums.UserRole;
|
||||||
import com.study.course_registration.repository.LessonRepository;
|
import com.study.course_registration.repository.LessonRepository;
|
||||||
import com.study.course_registration.repository.UserRepository;
|
import com.study.course_registration.repository.LessonScheduleRepository;
|
||||||
import com.study.course_registration.repository.SubjectRepository;
|
|
||||||
import com.study.course_registration.repository.ProfessorRepository;
|
import com.study.course_registration.repository.ProfessorRepository;
|
||||||
import com.study.course_registration.repository.UserLessonRepository;
|
import com.study.course_registration.repository.SemesterRepository;
|
||||||
|
import com.study.course_registration.repository.SubjectRepository;
|
||||||
import java.time.Instant;
|
import com.study.course_registration.repository.UserRepository;
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.time.ZonedDateTime;
|
|
||||||
import java.time.temporal.ChronoUnit;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.springframework.boot.CommandLineRunner;
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Profile ("local")
|
@ConditionalOnProperty(name = "app.seed.enabled", havingValue = "true")
|
||||||
public class DataSeeder implements CommandLineRunner {
|
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 Clock clock;
|
||||||
|
private final SemesterRepository semesterRepository;
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final LessonRepository lessonRepository;
|
|
||||||
private final SubjectRepository subjectRepository;
|
|
||||||
private final ProfessorRepository professorRepository;
|
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) {
|
public DataSeeder(Clock clock,
|
||||||
this.userRepository = userRepository;
|
SemesterRepository semesterRepository,
|
||||||
this.lessonRepository = lessonRepository;
|
ProfessorRepository professorRepository,
|
||||||
this.subjectRepository = subjectRepository;
|
SubjectRepository subjectRepository,
|
||||||
|
LessonRepository lessonRepository,
|
||||||
|
LessonScheduleRepository lessonScheduleRepository,
|
||||||
|
UserRepository userRepository) {
|
||||||
|
this.clock = clock;
|
||||||
|
this.semesterRepository = semesterRepository;
|
||||||
this.professorRepository = professorRepository;
|
this.professorRepository = professorRepository;
|
||||||
this.userLessonRepository = userLessonRepository;
|
this.subjectRepository = subjectRepository;
|
||||||
|
this.lessonRepository = lessonRepository;
|
||||||
|
this.lessonScheduleRepository = lessonScheduleRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void run(String... args) throws Exception {
|
public void run(String... args) {
|
||||||
if(userRepository.count() > 0) {
|
if (semesterRepository.existsById(SEMESTER_ID)) {
|
||||||
return;
|
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(
|
List<Professor> professors = professorRepository.saveAll(List.of(
|
||||||
new Professor("김영한"),
|
new Professor("10000000-0000-0000-0000-000000000001", "김영한"),
|
||||||
new Professor("최태영"),
|
new Professor("10000000-0000-0000-0000-000000000002", "최태영"),
|
||||||
new Professor("조재한")
|
new Professor("10000000-0000-0000-0000-000000000003", "조재한")));
|
||||||
));
|
|
||||||
|
|
||||||
List<Subject> subjects = subjectRepository.saveAll(List.of(
|
List<Subject> subjects = subjectRepository.saveAll(List.of(
|
||||||
new Subject("자바 프로그래밍", "CS201", "자바 프로그래밍 기초"),
|
new Subject("20000000-0000-0000-0000-000000000001", "자바 프로그래밍", "CS201", "자바 프로그래밍 기초", 3),
|
||||||
new Subject("자료구조", "CS202", "자료구조 및 알고리즘"),
|
new Subject("20000000-0000-0000-0000-000000000002", "자료구조", "CS202", "자료구조 및 알고리즘", 3),
|
||||||
new Subject("데이터베이스", "CS203", "데이터베이스 시스템")
|
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);
|
Lesson data01 = new Lesson(DATA_STRUCTURES_01_ID, "자료구조 01분반", subjects.get(1), professors.get(0), semester, 30, null, null);
|
||||||
List<Lesson> lessons = lessonRepository.saveAll(List.of(
|
Lesson data02 = new Lesson(DATA_STRUCTURES_02_ID, "자료구조 02분반", subjects.get(1), professors.get(1), semester, 30, null, null);
|
||||||
new Lesson("자바 프로그래밍", subjects.get(0), professors.get(0), kst(3, 2, 9), kst(3, 2, 11)),
|
Lesson os = new Lesson(OVERLAP_OS_ID, "운영체제", subjects.get(4), professors.get(1), semester, 30, null, null);
|
||||||
new Lesson("자료구조", subjects.get(1), professors.get(1), kst(3, 2, 13), kst(3, 2, 17)),
|
Lesson capacityOne = new Lesson(CAPACITY_ONE_ID, "데이터베이스 소수정예", subjects.get(2), professors.get(2), semester, 1, null, null);
|
||||||
new Lesson("데이터베이스", subjects.get(2), professors.get(2), kst(3, 3, 10), kst(3, 3, 14))
|
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(
|
lessonScheduleRepository.saveAll(List.of(
|
||||||
new User("학생1", 1L, UserRole.STUDENT),
|
schedule("50000000-0000-0000-0000-000000000001", data01, DayOfWeek.MONDAY, 9, 11),
|
||||||
new User("학생2", 2L, UserRole.STUDENT),
|
schedule("50000000-0000-0000-0000-000000000002", data02, DayOfWeek.TUESDAY, 9, 11),
|
||||||
new User("대학원생", 3L, UserRole.POSTGRADUATE)
|
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(
|
userRepository.saveAll(List.of(
|
||||||
new UserLesson(users.get(0), lessons.get(0)),
|
new User(STUDENT_GRADE_1_ID, "학생1", 1L, UserRole.STUDENT),
|
||||||
new UserLesson(users.get(0), lessons.get(1)),
|
new User(STUDENT_GRADE_2_ID, "학생2", 2L, UserRole.STUDENT),
|
||||||
new UserLesson(users.get(1), lessons.get(1)),
|
new User(POSTGRADUATE_ID, "대학원생", 3L, UserRole.POSTGRADUATE)));
|
||||||
new UserLesson(users.get(2), lessons.get(2))
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Instant kst(int month, int day, int hour) {
|
private static LessonSchedule schedule(String id, Lesson lesson, DayOfWeek day, int startHour, int endHour) {
|
||||||
return ZonedDateTime.of(2026, month, day, hour, 0, 0, 0, KST).toInstant();
|
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;
|
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 org.springframework.stereotype.Service;
|
||||||
import com.study.course_registration.dto.ResponseDto;
|
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.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.UserLessonRepository;
|
||||||
import com.study.course_registration.repository.UserRepository;
|
import com.study.course_registration.repository.UserRepository;
|
||||||
import com.study.course_registration.repository.SubjectRepository;
|
import com.study.course_registration.service.policy.RegistrationValidator;
|
||||||
import com.study.course_registration.repository.ProfessorRepository;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CourseRegistrationService {
|
public class CourseRegistrationService {
|
||||||
|
private final Clock clock;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final LessonRepository lessonRepository;
|
private final LessonRepository lessonRepository;
|
||||||
private final SubjectRepository subjectRepository;
|
private final SemesterRepository semesterRepository;
|
||||||
private final ProfessorRepository professorRepository;
|
private final LessonScheduleRepository lessonScheduleRepository;
|
||||||
private final UserLessonRepository userLessonRepository;
|
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.userRepository = userRepository;
|
||||||
this.lessonRepository = lessonRepository;
|
this.lessonRepository = lessonRepository;
|
||||||
this.subjectRepository = subjectRepository;
|
this.semesterRepository = semesterRepository;
|
||||||
this.professorRepository = professorRepository;
|
this.lessonScheduleRepository = lessonScheduleRepository;
|
||||||
this.userLessonRepository = userLessonRepository;
|
this.userLessonRepository = userLessonRepository;
|
||||||
|
this.validator = validator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ResponseDto registerCourse(RequestDto requestDto) {
|
@Transactional
|
||||||
|
public CourseRegistrationResponse register(String userId, CourseRegistrationRequest request) {
|
||||||
return new ResponseDto();
|
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:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:h2:mem:course_db
|
url: jdbc:h2:mem:course_db;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;LOCK_TIMEOUT=3000
|
||||||
driver-class-name: org.h2.Driver
|
driver-class-name: org.h2.Driver
|
||||||
username: sa
|
username: sa
|
||||||
password:
|
password:
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: create-drop
|
ddl-auto: create-drop
|
||||||
show-sql: true
|
show-sql: true
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: true
|
format_sql: true
|
||||||
h2:
|
h2:
|
||||||
console:
|
console:
|
||||||
enabled: true
|
enabled: true
|
||||||
path: /h2-console
|
path: /h2-console
|
||||||
|
|
||||||
|
app:
|
||||||
|
seed:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
org.hibernate.SQL: debug
|
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:
|
server:
|
||||||
application:
|
address: ${SERVER_ADDRESS:127.0.0.1}
|
||||||
name: ${APP_NAME:course-registration}
|
port: ${SERVER_PORT:8080}
|
||||||
profiles:
|
|
||||||
active: ${APP_PROFILE:local}
|
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
jackson:
|
application:
|
||||||
time-zone: UTC
|
name: ${APP_NAME:course-registration}
|
||||||
h2:
|
profiles:
|
||||||
console:
|
default: local
|
||||||
enabled: true
|
jackson:
|
||||||
path: /h2-console
|
time-zone: UTC
|
||||||
datasource:
|
jpa:
|
||||||
url: jdbc:h2:mem:course_db
|
open-in-view: false
|
||||||
username: sa
|
|
||||||
password:
|
|
||||||
driver-class-name: org.h2.Driver
|
|
||||||
jpa:
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: create
|
|
||||||
show-sql: true
|
|
||||||
properties:
|
|
||||||
hibernate:
|
|
||||||
format_sql: true
|
|
||||||
|
|
||||||
logging.level:
|
springdoc:
|
||||||
org.hibernate.SQL: debug
|
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