From 70d71008c69729d3a585c7e2fdf7cd9f38a4fabb Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Fri, 18 Sep 2026 15:20:54 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20=EC=88=98=EA=B0=95=EC=8B=A0=EC=B2=AD?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 8 + .env.example | 10 + .gitignore | 6 + Dockerfile | 17 + README.md | 144 +++++++++ build.gradle | 4 +- docker-compose.yml | 44 +++ ...9-17-course-registration-implementation.md | 64 ++++ ...ourse-registration.postman_collection.json | 298 ++++++++++++++++++ postman/dev.postman_environment.json | 76 +++++ postman/local.postman_environment.json | 76 +++++ .../config/ClockConfig.java | 14 + .../config/OpenApiConfig.java | 18 ++ .../CourseRegistrationController.java | 64 +++- .../controller/LessonController.java | 45 +++ .../course_registration/dto/RequestDto.java | 7 - .../course_registration/dto/ResponseDto.java | 7 - .../dto/ScheduleResponse.java | 15 + .../dto/lesson/LessonDetailResponse.java | 25 ++ .../dto/lesson/LessonItemResponse.java | 20 ++ .../dto/lesson/LessonListResponse.java | 10 + .../CourseRegistrationRequest.java | 7 + .../CourseRegistrationResponse.java | 14 + .../RegistrationItemResponse.java | 18 ++ .../RegistrationListResponse.java | 12 + .../entity/BaseCreateEntity.java | 22 +- .../entity/BaseEntity.java | 28 +- .../entity/BaseTimeZoneEntity.java | 23 +- .../course_registration/entity/Lesson.java | 73 +++-- .../entity/LessonSchedule.java | 35 +- .../course_registration/entity/Professor.java | 17 +- .../course_registration/entity/Semester.java | 51 ++- .../course_registration/entity/Subject.java | 43 ++- .../course_registration/entity/User.java | 26 +- .../entity/UserLesson.java | 56 ++-- .../entity/UserLessonId.java | 21 -- .../enums/RegistrationErrorCode.java | 32 ++ .../exception/ErrorResponse.java | 6 + .../exception/GlobalExceptionHandler.java | 54 ++++ .../exception/RegistrationException.java | 16 + .../repository/LessonRepository.java | 27 +- .../repository/LessonScheduleRepository.java | 14 + .../repository/SemesterRepository.java | 13 + .../repository/UserLessonRepository.java | 50 ++- .../repository/UserRepository.java | 14 +- .../seeder/DataSeeder.java | 140 ++++---- .../service/CourseRegistrationService.java | 179 ++++++++++- .../service/LessonQueryService.java | 126 ++++++++ .../service/policy/RegistrationValidator.java | 100 ++++++ .../policy/ScheduleConflictChecker.java | 14 + src/main/resources/application-dev.yaml | 16 + src/main/resources/application-local.yaml | 40 +-- src/main/resources/application-prod.yaml | 22 ++ src/main/resources/application.yaml | 50 +-- ...egistrationConcurrencyIntegrationTest.java | 202 ++++++++++++ ...rseRegistrationServiceIntegrationTest.java | 171 ++++++++++ .../LessonQueryServiceIntegrationTest.java | 96 ++++++ .../policy/RegistrationValidatorTest.java | 163 ++++++++++ .../policy/ScheduleConflictCheckerTest.java | 46 +++ .../service/policy/TestFixtures.java | 63 ++++ src/test/resources/application-test.yaml | 21 ++ 61 files changed, 2835 insertions(+), 258 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/superpowers/plans/2026-09-17-course-registration-implementation.md create mode 100644 postman/course-registration.postman_collection.json create mode 100644 postman/dev.postman_environment.json create mode 100644 postman/local.postman_environment.json create mode 100644 src/main/java/com/study/course_registration/config/ClockConfig.java create mode 100644 src/main/java/com/study/course_registration/config/OpenApiConfig.java create mode 100644 src/main/java/com/study/course_registration/controller/LessonController.java delete mode 100644 src/main/java/com/study/course_registration/dto/RequestDto.java delete mode 100644 src/main/java/com/study/course_registration/dto/ResponseDto.java create mode 100644 src/main/java/com/study/course_registration/dto/ScheduleResponse.java create mode 100644 src/main/java/com/study/course_registration/dto/lesson/LessonDetailResponse.java create mode 100644 src/main/java/com/study/course_registration/dto/lesson/LessonItemResponse.java create mode 100644 src/main/java/com/study/course_registration/dto/lesson/LessonListResponse.java create mode 100644 src/main/java/com/study/course_registration/dto/registration/CourseRegistrationRequest.java create mode 100644 src/main/java/com/study/course_registration/dto/registration/CourseRegistrationResponse.java create mode 100644 src/main/java/com/study/course_registration/dto/registration/RegistrationItemResponse.java create mode 100644 src/main/java/com/study/course_registration/dto/registration/RegistrationListResponse.java delete mode 100644 src/main/java/com/study/course_registration/entity/UserLessonId.java create mode 100644 src/main/java/com/study/course_registration/enums/RegistrationErrorCode.java create mode 100644 src/main/java/com/study/course_registration/exception/ErrorResponse.java create mode 100644 src/main/java/com/study/course_registration/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/study/course_registration/exception/RegistrationException.java create mode 100644 src/main/java/com/study/course_registration/repository/LessonScheduleRepository.java create mode 100644 src/main/java/com/study/course_registration/repository/SemesterRepository.java create mode 100644 src/main/java/com/study/course_registration/service/LessonQueryService.java create mode 100644 src/main/java/com/study/course_registration/service/policy/RegistrationValidator.java create mode 100644 src/main/java/com/study/course_registration/service/policy/ScheduleConflictChecker.java create mode 100644 src/test/java/com/study/course_registration/service/CourseRegistrationConcurrencyIntegrationTest.java create mode 100644 src/test/java/com/study/course_registration/service/CourseRegistrationServiceIntegrationTest.java create mode 100644 src/test/java/com/study/course_registration/service/LessonQueryServiceIntegrationTest.java create mode 100644 src/test/java/com/study/course_registration/service/policy/RegistrationValidatorTest.java create mode 100644 src/test/java/com/study/course_registration/service/policy/ScheduleConflictCheckerTest.java create mode 100644 src/test/java/com/study/course_registration/service/policy/TestFixtures.java create mode 100644 src/test/resources/application-test.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8568a7f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gradle +build +.idea +.vscode +.env +.env.local +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6359c24 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index c2065bc..69403ec 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ out/ ### VS Code ### .vscode/ + +### Local environment ### +.env +.env.local +.env.*.local +!.env.example diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b3c388d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index e9abbaf..8b37b4a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,146 @@ # 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명 +- 동일 학생의 학점 한도 경쟁 +- 동일 학생의 시간표 충돌 경쟁 +- 동일 학생의 같은 강의 중복 경쟁 diff --git a/build.gradle b/build.gradle index d615283..cbbb80b 100644 --- a/build.gradle +++ b/build.gradle @@ -28,9 +28,11 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' //Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1' //Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' //Jackson implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' //H2 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ea14bb1 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/superpowers/plans/2026-09-17-course-registration-implementation.md b/docs/superpowers/plans/2026-09-17-course-registration-implementation.md new file mode 100644 index 0000000..cb0dea6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-course-registration-implementation.md @@ -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. diff --git a/postman/course-registration.postman_collection.json b/postman/course-registration.postman_collection.json new file mode 100644 index 0000000..0f03da5 --- /dev/null +++ b/postman/course-registration.postman_collection.json @@ -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}}\"}" + } + } + } + ] + } + ] +} diff --git a/postman/dev.postman_environment.json b/postman/dev.postman_environment.json new file mode 100644 index 0000000..5e47bea --- /dev/null +++ b/postman/dev.postman_environment.json @@ -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" +} diff --git a/postman/local.postman_environment.json b/postman/local.postman_environment.json new file mode 100644 index 0000000..7b1ba29 --- /dev/null +++ b/postman/local.postman_environment.json @@ -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" +} diff --git a/src/main/java/com/study/course_registration/config/ClockConfig.java b/src/main/java/com/study/course_registration/config/ClockConfig.java new file mode 100644 index 0000000..e6cdbe0 --- /dev/null +++ b/src/main/java/com/study/course_registration/config/ClockConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/study/course_registration/config/OpenApiConfig.java b/src/main/java/com/study/course_registration/config/OpenApiConfig.java new file mode 100644 index 0000000..b4fbeb0 --- /dev/null +++ b/src/main/java/com/study/course_registration/config/OpenApiConfig.java @@ -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")); + } +} diff --git a/src/main/java/com/study/course_registration/controller/CourseRegistrationController.java b/src/main/java/com/study/course_registration/controller/CourseRegistrationController.java index 02d0686..e64e8de 100644 --- a/src/main/java/com/study/course_registration/controller/CourseRegistrationController.java +++ b/src/main/java/com/study/course_registration/controller/CourseRegistrationController.java @@ -1,38 +1,70 @@ package com.study.course_registration.controller; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; - +import org.springframework.http.HttpStatus; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import com.study.course_registration.dto.registration.CourseRegistrationRequest; +import com.study.course_registration.dto.registration.CourseRegistrationResponse; +import com.study.course_registration.dto.registration.RegistrationListResponse; import com.study.course_registration.service.CourseRegistrationService; -import com.study.course_registration.dto.RequestDto; -import com.study.course_registration.dto.ResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; @RestController -@RequestMapping ("/api/v1") +@Validated +@RequestMapping("/api/v1/users/{userId}/registrations") +@Tag(name = "Registrations", description = "수강신청/조회/취소 API") public class CourseRegistrationController { - private final CourseRegistrationService courseRegistrationService; - + public CourseRegistrationController(CourseRegistrationService courseRegistrationService) { this.courseRegistrationService = courseRegistrationService; } - @PostMapping("/course-register") - @Operation(summary = "Register a course", - description = "Registers a new course with the provided details.") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "수강신청") @ApiResponses({ - @ApiResponse (responseCode = "200", description = "Course registered successfully"), - @ApiResponse (responseCode = "400", description = "Invalid request data") + @ApiResponse(responseCode = "201", description = "신청 성공"), + @ApiResponse(responseCode = "403", description = "학년 또는 역할 제한", content = @Content), + @ApiResponse(responseCode = "404", description = "사용자 또는 강의 없음", content = @Content), + @ApiResponse(responseCode = "409", description = "기간/중복/시간표/학점/정원 충돌", content = @Content), + @ApiResponse(responseCode = "503", description = "락 대기 타임아웃", content = @Content) }) - public ResponseDto registerCourse(@Validated RequestDto requestDto) { - return courseRegistrationService.registerCourse(requestDto); + public CourseRegistrationResponse register( + @PathVariable String userId, + @Valid @RequestBody CourseRegistrationRequest request) { + return courseRegistrationService.register(userId, request); } + @GetMapping + @Operation(summary = "내 수강신청 목록 조회") + public RegistrationListResponse getRegistrations( + @PathVariable String userId, + @RequestParam(required = false) String semesterId) { + return courseRegistrationService.getRegistrations(userId, semesterId); + } + + @DeleteMapping("/{registrationId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "수강신청 취소") + public void cancel( + @PathVariable String userId, + @PathVariable String registrationId) { + courseRegistrationService.cancel(userId, registrationId); + } } diff --git a/src/main/java/com/study/course_registration/controller/LessonController.java b/src/main/java/com/study/course_registration/controller/LessonController.java new file mode 100644 index 0000000..2d0932c --- /dev/null +++ b/src/main/java/com/study/course_registration/controller/LessonController.java @@ -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); + } +} diff --git a/src/main/java/com/study/course_registration/dto/RequestDto.java b/src/main/java/com/study/course_registration/dto/RequestDto.java deleted file mode 100644 index 57831c6..0000000 --- a/src/main/java/com/study/course_registration/dto/RequestDto.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.study.course_registration.dto; - -public record RequestDto( - -) { - -} diff --git a/src/main/java/com/study/course_registration/dto/ResponseDto.java b/src/main/java/com/study/course_registration/dto/ResponseDto.java deleted file mode 100644 index 5e566c0..0000000 --- a/src/main/java/com/study/course_registration/dto/ResponseDto.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.study.course_registration.dto; - -public record ResponseDto( - -) { - -} diff --git a/src/main/java/com/study/course_registration/dto/ScheduleResponse.java b/src/main/java/com/study/course_registration/dto/ScheduleResponse.java new file mode 100644 index 0000000..29dbee2 --- /dev/null +++ b/src/main/java/com/study/course_registration/dto/ScheduleResponse.java @@ -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()); + } +} diff --git a/src/main/java/com/study/course_registration/dto/lesson/LessonDetailResponse.java b/src/main/java/com/study/course_registration/dto/lesson/LessonDetailResponse.java new file mode 100644 index 0000000..dcdcd2b --- /dev/null +++ b/src/main/java/com/study/course_registration/dto/lesson/LessonDetailResponse.java @@ -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 schedules) { +} diff --git a/src/main/java/com/study/course_registration/dto/lesson/LessonItemResponse.java b/src/main/java/com/study/course_registration/dto/lesson/LessonItemResponse.java new file mode 100644 index 0000000..6c5407e --- /dev/null +++ b/src/main/java/com/study/course_registration/dto/lesson/LessonItemResponse.java @@ -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 schedules) { +} diff --git a/src/main/java/com/study/course_registration/dto/lesson/LessonListResponse.java b/src/main/java/com/study/course_registration/dto/lesson/LessonListResponse.java new file mode 100644 index 0000000..521b534 --- /dev/null +++ b/src/main/java/com/study/course_registration/dto/lesson/LessonListResponse.java @@ -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 lessons) { +} diff --git a/src/main/java/com/study/course_registration/dto/registration/CourseRegistrationRequest.java b/src/main/java/com/study/course_registration/dto/registration/CourseRegistrationRequest.java new file mode 100644 index 0000000..69b4f6b --- /dev/null +++ b/src/main/java/com/study/course_registration/dto/registration/CourseRegistrationRequest.java @@ -0,0 +1,7 @@ +package com.study.course_registration.dto.registration; + +import jakarta.validation.constraints.NotBlank; + +public record CourseRegistrationRequest( + @NotBlank(message = "lessonId는 필수입니다.") String lessonId) { +} diff --git a/src/main/java/com/study/course_registration/dto/registration/CourseRegistrationResponse.java b/src/main/java/com/study/course_registration/dto/registration/CourseRegistrationResponse.java new file mode 100644 index 0000000..a7814df --- /dev/null +++ b/src/main/java/com/study/course_registration/dto/registration/CourseRegistrationResponse.java @@ -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) { +} diff --git a/src/main/java/com/study/course_registration/dto/registration/RegistrationItemResponse.java b/src/main/java/com/study/course_registration/dto/registration/RegistrationItemResponse.java new file mode 100644 index 0000000..e2eabe2 --- /dev/null +++ b/src/main/java/com/study/course_registration/dto/registration/RegistrationItemResponse.java @@ -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 schedules, + Instant registeredAt) { +} diff --git a/src/main/java/com/study/course_registration/dto/registration/RegistrationListResponse.java b/src/main/java/com/study/course_registration/dto/registration/RegistrationListResponse.java new file mode 100644 index 0000000..914ecdd --- /dev/null +++ b/src/main/java/com/study/course_registration/dto/registration/RegistrationListResponse.java @@ -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 registrations) { +} diff --git a/src/main/java/com/study/course_registration/entity/BaseCreateEntity.java b/src/main/java/com/study/course_registration/entity/BaseCreateEntity.java index 009a49f..6ee8cdb 100644 --- a/src/main/java/com/study/course_registration/entity/BaseCreateEntity.java +++ b/src/main/java/com/study/course_registration/entity/BaseCreateEntity.java @@ -5,11 +5,23 @@ import java.time.Instant; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; -@MappedSuperclass -@EntityListeners (AuditingEntityListener.class) -public class BaseCreateEntity { - @CreatedDate +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseCreateEntity extends BaseEntity { + @CreatedDate + @Column(nullable = false, updatable = false) private Instant createdAt; + + protected BaseCreateEntity() { + } + + protected BaseCreateEntity(String id) { + super(id); + } } diff --git a/src/main/java/com/study/course_registration/entity/BaseEntity.java b/src/main/java/com/study/course_registration/entity/BaseEntity.java index fddfcaa..6cdc0e8 100644 --- a/src/main/java/com/study/course_registration/entity/BaseEntity.java +++ b/src/main/java/com/study/course_registration/entity/BaseEntity.java @@ -1,15 +1,31 @@ package com.study.course_registration.entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; +import java.util.UUID; + +import jakarta.persistence.Column; import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; import lombok.Getter; @Getter -@MappedSuperclass -public class BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) +@MappedSuperclass +public abstract class BaseEntity { + @Id + @Column(length = 36, nullable = false, updatable = false) private String id; + + protected BaseEntity() { + } + + protected BaseEntity(String id) { + this.id = id; + } + + @PrePersist + protected void assignId() { + if (id == null) { + id = UUID.randomUUID().toString(); + } + } } diff --git a/src/main/java/com/study/course_registration/entity/BaseTimeZoneEntity.java b/src/main/java/com/study/course_registration/entity/BaseTimeZoneEntity.java index 7bb7acc..58d68ec 100644 --- a/src/main/java/com/study/course_registration/entity/BaseTimeZoneEntity.java +++ b/src/main/java/com/study/course_registration/entity/BaseTimeZoneEntity.java @@ -5,15 +5,28 @@ import java.time.Instant; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; +import lombok.Getter; -@MappedSuperclass -@EntityListeners (AuditingEntityListener.class) -public class BaseTimeZoneEntity extends BaseEntity { - @CreatedDate +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeZoneEntity extends BaseEntity { + @CreatedDate + @Column(nullable = false, updatable = false) private Instant createdAt; - @LastModifiedDate + @LastModifiedDate + @Column(nullable = false) private Instant updatedAt; + + protected BaseTimeZoneEntity() { + } + + protected BaseTimeZoneEntity(String id) { + super(id); + } } diff --git a/src/main/java/com/study/course_registration/entity/Lesson.java b/src/main/java/com/study/course_registration/entity/Lesson.java index 6430965..12af3fd 100644 --- a/src/main/java/com/study/course_registration/entity/Lesson.java +++ b/src/main/java/com/study/course_registration/entity/Lesson.java @@ -1,53 +1,74 @@ package com.study.course_registration.entity; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.Instant; +import java.util.Objects; import com.study.course_registration.enums.UserRole; -@Entity -@NoArgsConstructor -@Getter +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter @Table(name = "lessons") public class Lesson extends BaseTimeZoneEntity { + @Column(nullable = false) private String name; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "subject_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_subject")) private Subject subject; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "professor_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_professor")) private Professor professor; - private Instant startTime; - - private Instant endTime; - - @ManyToOne(fetch = FetchType.LAZY) - @Column (name = "semester_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "semester_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_semester")) private Semester semester; - @Column (name = "capacity", nullable = false) + @Column(nullable = false) private Integer capacity; - @Column (name = "min_grade", nullable = false) + @Column(name = "min_grade") private Long minGrade; - @Column (name = "allowed_role", nullable = false) + @Enumerated(EnumType.STRING) + @Column(name = "allowed_role") private UserRole allowedRole; - public Lesson(String name, Subject subject, Professor professor, Instant startTime, Instant endTime, Semester semester, Integer capacity, Long minGrade, UserRole allowedRole) { + public Lesson(String name, Subject subject, Professor professor, Semester semester, + Integer capacity, Long minGrade, UserRole allowedRole) { + this(null, name, subject, professor, semester, capacity, minGrade, allowedRole); + } + + public Lesson(String id, String name, Subject subject, Professor professor, Semester semester, + Integer capacity, Long minGrade, UserRole allowedRole) { + super(id); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + if (capacity == null || capacity < 1) { + throw new IllegalArgumentException("capacity must be at least 1"); + } + if (minGrade != null && minGrade < 1) { + throw new IllegalArgumentException("minGrade must be at least 1 when present"); + } this.name = name; - this.subject = subject; - this.professor = professor; - this.startTime = startTime; - this.endTime = endTime; - this.semester = semester; + this.subject = Objects.requireNonNull(subject, "subject"); + this.professor = Objects.requireNonNull(professor, "professor"); + this.semester = Objects.requireNonNull(semester, "semester"); this.capacity = capacity; this.minGrade = minGrade; this.allowedRole = allowedRole; - } } diff --git a/src/main/java/com/study/course_registration/entity/LessonSchedule.java b/src/main/java/com/study/course_registration/entity/LessonSchedule.java index 2d363ca..17923d3 100644 --- a/src/main/java/com/study/course_registration/entity/LessonSchedule.java +++ b/src/main/java/com/study/course_registration/entity/LessonSchedule.java @@ -2,26 +2,51 @@ package com.study.course_registration.entity; import java.time.DayOfWeek; import java.time.LocalTime; +import java.util.Objects; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; -@Entity +@Entity @Getter -@NoArgsConstructor +@NoArgsConstructor +@Table(name = "lesson_schedules") public class LessonSchedule extends BaseTimeZoneEntity { - @ManyToOne(fetch = FetchType.LAZY) - @Column() + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_schedule_lesson")) private Lesson lesson; - @Column() + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) private DayOfWeek dayOfWeek; + @Column(nullable = false) private LocalTime startTime; + @Column(nullable = false) private LocalTime endTime; + + public LessonSchedule(Lesson lesson, DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) { + this(null, lesson, dayOfWeek, startTime, endTime); + } + + public LessonSchedule(String id, Lesson lesson, DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) { + super(id); + this.lesson = Objects.requireNonNull(lesson, "lesson"); + this.dayOfWeek = Objects.requireNonNull(dayOfWeek, "dayOfWeek"); + this.startTime = Objects.requireNonNull(startTime, "startTime"); + this.endTime = Objects.requireNonNull(endTime, "endTime"); + if (!startTime.isBefore(endTime)) { + throw new IllegalArgumentException("startTime must be before endTime"); + } + } } diff --git a/src/main/java/com/study/course_registration/entity/Professor.java b/src/main/java/com/study/course_registration/entity/Professor.java index 061b151..cd3ba4c 100644 --- a/src/main/java/com/study/course_registration/entity/Professor.java +++ b/src/main/java/com/study/course_registration/entity/Professor.java @@ -1,17 +1,28 @@ package com.study.course_registration.entity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; -@Entity -@NoArgsConstructor +@Entity +@NoArgsConstructor @Getter @Table(name = "professors") public class Professor extends BaseTimeZoneEntity { + @Column(nullable = false) private String name; public Professor(String name) { + this(null, name); + } + + public Professor(String id, String name) { + super(id); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } this.name = name; } } diff --git a/src/main/java/com/study/course_registration/entity/Semester.java b/src/main/java/com/study/course_registration/entity/Semester.java index f3d942e..7a0882a 100644 --- a/src/main/java/com/study/course_registration/entity/Semester.java +++ b/src/main/java/com/study/course_registration/entity/Semester.java @@ -2,28 +2,67 @@ package com.study.course_registration.entity; import java.time.Instant; import java.time.LocalDate; +import java.util.Objects; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.Getter; import lombok.NoArgsConstructor; -@Entity -@NoArgsConstructor -@Getter +@Entity +@Getter +@NoArgsConstructor +@Table(name = "semesters", uniqueConstraints = @UniqueConstraint(name = "uk_semester_name", columnNames = "name")) public class Semester extends BaseTimeZoneEntity { + @Column(nullable = false) private String name; + @Column(nullable = false) private LocalDate startDate; + @Column(nullable = false) private LocalDate endDate; + @Column(nullable = false) private Instant registrationStartAt; + @Column(nullable = false) private Instant registrationEndAt; + @Column(nullable = false) private Integer maxCredits; - public Semester(String name) { - this.name = name; + public Semester(String name, LocalDate startDate, LocalDate endDate, + Instant registrationStartAt, Instant registrationEndAt, Integer maxCredits) { + this(null, name, startDate, endDate, registrationStartAt, registrationEndAt, maxCredits); + } + + public Semester(String id, String name, LocalDate startDate, LocalDate endDate, + Instant registrationStartAt, Instant registrationEndAt, Integer maxCredits) { + super(id); + this.name = requireText(name, "name"); + this.startDate = Objects.requireNonNull(startDate, "startDate"); + this.endDate = Objects.requireNonNull(endDate, "endDate"); + this.registrationStartAt = Objects.requireNonNull(registrationStartAt, "registrationStartAt"); + this.registrationEndAt = Objects.requireNonNull(registrationEndAt, "registrationEndAt"); + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("endDate must be on or after startDate"); + } + if (!registrationStartAt.isBefore(registrationEndAt)) { + throw new IllegalArgumentException("registrationStartAt must be before registrationEndAt"); + } + if (maxCredits == null || maxCredits < 1) { + throw new IllegalArgumentException("maxCredits must be at least 1"); + } + this.maxCredits = maxCredits; + } + + private static String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value; } } diff --git a/src/main/java/com/study/course_registration/entity/Subject.java b/src/main/java/com/study/course_registration/entity/Subject.java index 671262a..38722c7 100644 --- a/src/main/java/com/study/course_registration/entity/Subject.java +++ b/src/main/java/com/study/course_registration/entity/Subject.java @@ -1,23 +1,48 @@ package com.study.course_registration.entity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.Getter; import lombok.NoArgsConstructor; -@Entity -@NoArgsConstructor -@Getter -@Table(name = "subjects") +@Entity +@NoArgsConstructor +@Getter +@Table(name = "subjects", uniqueConstraints = @UniqueConstraint(name = "uk_subject_code", columnNames = "code")) public class Subject extends BaseTimeZoneEntity { + @Column(nullable = false) private String name; + @Column(nullable = false) private String code; + @Column(nullable = false, length = 1000) private String description; - public Subject(String name, String code, String description) { - this.name = name; - this.code = code; - this.description = description; + @Column(nullable = false) + private Integer credit; + + public Subject(String name, String code, String description, Integer credit) { + this(null, name, code, description, credit); + } + + public Subject(String id, String name, String code, String description, Integer credit) { + super(id); + if (credit == null || credit < 1) { + throw new IllegalArgumentException("credit must be at least 1"); + } + this.name = requireText(name, "name"); + this.code = requireText(code, "code"); + this.description = description == null ? "" : description; + this.credit = credit; + } + + private static String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value; } } diff --git a/src/main/java/com/study/course_registration/entity/User.java b/src/main/java/com/study/course_registration/entity/User.java index 6a90f5c..5bc53a9 100644 --- a/src/main/java/com/study/course_registration/entity/User.java +++ b/src/main/java/com/study/course_registration/entity/User.java @@ -1,26 +1,46 @@ package com.study.course_registration.entity; +import java.util.Objects; + import com.study.course_registration.enums.UserRole; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Getter +@Getter @NoArgsConstructor @Table(name = "users") public class User extends BaseTimeZoneEntity { + @Column(nullable = false) private String name; + @Column(nullable = false) private Long grade; @Enumerated(EnumType.STRING) + @Column(nullable = false) private UserRole role; public User(String name, Long grade, UserRole role) { + this(null, name, grade, role); + } + + public User(String id, String name, Long grade, UserRole role) { + super(id); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + if (grade == null || grade < 1) { + throw new IllegalArgumentException("grade must be at least 1"); + } this.name = name; this.grade = grade; - this.role = role; + this.role = Objects.requireNonNull(role, "role"); } } diff --git a/src/main/java/com/study/course_registration/entity/UserLesson.java b/src/main/java/com/study/course_registration/entity/UserLesson.java index d8a0185..2621548 100644 --- a/src/main/java/com/study/course_registration/entity/UserLesson.java +++ b/src/main/java/com/study/course_registration/entity/UserLesson.java @@ -1,36 +1,52 @@ package com.study.course_registration.entity; -import jakarta.persistence.*; +import java.time.Instant; +import java.util.Objects; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; -@Entity +@Entity @NoArgsConstructor @Getter @Table(name = "user_lessons") public class UserLesson extends BaseCreateEntity { - - @EmbeddedId - private UserLessonId id; - - @MapsId("userId") - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn ( - name = "user_id", - foreignKey = @ForeignKey(name = "fk_user_lesson_user_id") - ) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_user")) private User user; - @MapsId("lessonId") - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn ( - name = "lesson_id", - foreignKey = @ForeignKey(name = "fk_user_lesson_lesson_id") - ) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_lesson")) private Lesson lesson; + @Column(name = "canceled_at") + private Instant canceledAt; + public UserLesson(User user, Lesson lesson) { - this.user = user; - this.lesson = lesson; + this(null, user, lesson); + } + + public UserLesson(String id, User user, Lesson lesson) { + super(id); + this.user = Objects.requireNonNull(user, "user"); + this.lesson = Objects.requireNonNull(lesson, "lesson"); + } + + public void cancel(Instant canceledAt) { + if (this.canceledAt != null) { + throw new IllegalStateException("registration is already canceled"); + } + this.canceledAt = Objects.requireNonNull(canceledAt, "canceledAt"); + } + + public boolean isCanceled() { + return canceledAt != null; } } diff --git a/src/main/java/com/study/course_registration/entity/UserLessonId.java b/src/main/java/com/study/course_registration/entity/UserLessonId.java deleted file mode 100644 index ae89dbf..0000000 --- a/src/main/java/com/study/course_registration/entity/UserLessonId.java +++ /dev/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; - } -} diff --git a/src/main/java/com/study/course_registration/enums/RegistrationErrorCode.java b/src/main/java/com/study/course_registration/enums/RegistrationErrorCode.java new file mode 100644 index 0000000..00ee89a --- /dev/null +++ b/src/main/java/com/study/course_registration/enums/RegistrationErrorCode.java @@ -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; + } +} diff --git a/src/main/java/com/study/course_registration/exception/ErrorResponse.java b/src/main/java/com/study/course_registration/exception/ErrorResponse.java new file mode 100644 index 0000000..904e1ec --- /dev/null +++ b/src/main/java/com/study/course_registration/exception/ErrorResponse.java @@ -0,0 +1,6 @@ +package com.study.course_registration.exception; + +import java.time.Instant; + +public record ErrorResponse(String code, String message, Instant timestamp) { +} diff --git a/src/main/java/com/study/course_registration/exception/GlobalExceptionHandler.java b/src/main/java/com/study/course_registration/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..da1a11f --- /dev/null +++ b/src/main/java/com/study/course_registration/exception/GlobalExceptionHandler.java @@ -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 handleRegistrationException(RegistrationException exception) { + return response(exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + ResponseEntity 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 handleBadRequest(Exception exception) { + return response(RegistrationErrorCode.VALIDATION_ERROR, "요청 값이 올바르지 않습니다."); + } + + @ExceptionHandler({LockTimeoutException.class, PessimisticLockException.class, PessimisticLockingFailureException.class}) + ResponseEntity handleLockTimeout(Exception exception) { + return response(RegistrationErrorCode.LOCK_TIMEOUT, "다른 요청이 처리 중입니다. 잠시 후 다시 시도해 주세요."); + } + + private ResponseEntity response(RegistrationErrorCode code, String message) { + return ResponseEntity.status(code.getStatus()) + .body(new ErrorResponse(code.name(), message, clock.instant())); + } +} diff --git a/src/main/java/com/study/course_registration/exception/RegistrationException.java b/src/main/java/com/study/course_registration/exception/RegistrationException.java new file mode 100644 index 0000000..1c6ff4f --- /dev/null +++ b/src/main/java/com/study/course_registration/exception/RegistrationException.java @@ -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; + } +} diff --git a/src/main/java/com/study/course_registration/repository/LessonRepository.java b/src/main/java/com/study/course_registration/repository/LessonRepository.java index 7f24d8b..9385b61 100644 --- a/src/main/java/com/study/course_registration/repository/LessonRepository.java +++ b/src/main/java/com/study/course_registration/repository/LessonRepository.java @@ -1,9 +1,34 @@ package com.study.course_registration.repository; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; import com.study.course_registration.entity.Lesson; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; + public interface LessonRepository extends JpaRepository { - + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) + @Query("select l from Lesson l where l.id = :id") + Optional findByIdForUpdate(@Param("id") String id); + + @EntityGraph(attributePaths = {"subject", "professor", "semester"}) + Page findBySemester_Id(String semesterId, Pageable pageable); + + @EntityGraph(attributePaths = {"subject", "professor", "semester"}) + Page 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 findDetailedById(@Param("id") String id); } diff --git a/src/main/java/com/study/course_registration/repository/LessonScheduleRepository.java b/src/main/java/com/study/course_registration/repository/LessonScheduleRepository.java new file mode 100644 index 0000000..1623ba0 --- /dev/null +++ b/src/main/java/com/study/course_registration/repository/LessonScheduleRepository.java @@ -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 { + List findByLesson_IdOrderByDayOfWeekAscStartTimeAsc(String lessonId); + + List findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(Collection lessonIds); +} diff --git a/src/main/java/com/study/course_registration/repository/SemesterRepository.java b/src/main/java/com/study/course_registration/repository/SemesterRepository.java new file mode 100644 index 0000000..f420144 --- /dev/null +++ b/src/main/java/com/study/course_registration/repository/SemesterRepository.java @@ -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 { + Optional findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc( + LocalDate startDate, LocalDate endDate); +} diff --git a/src/main/java/com/study/course_registration/repository/UserLessonRepository.java b/src/main/java/com/study/course_registration/repository/UserLessonRepository.java index 38538f4..0a036c5 100644 --- a/src/main/java/com/study/course_registration/repository/UserLessonRepository.java +++ b/src/main/java/com/study/course_registration/repository/UserLessonRepository.java @@ -1,10 +1,54 @@ package com.study.course_registration.repository; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.study.course_registration.entity.UserLesson; -import com.study.course_registration.entity.UserLessonId; -public interface UserLessonRepository extends JpaRepository { - +public interface UserLessonRepository extends JpaRepository { + @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 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 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 countActiveByLessonIds(@Param("lessonIds") Collection lessonIds); + + interface RegistrationTarget { + String getUserId(); + String getLessonId(); + } + + interface LessonEnrollmentCount { + String getLessonId(); + long getEnrolledCount(); + } } diff --git a/src/main/java/com/study/course_registration/repository/UserRepository.java b/src/main/java/com/study/course_registration/repository/UserRepository.java index 0a26ccd..657ac79 100644 --- a/src/main/java/com/study/course_registration/repository/UserRepository.java +++ b/src/main/java/com/study/course_registration/repository/UserRepository.java @@ -1,9 +1,21 @@ package com.study.course_registration.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; import com.study.course_registration.entity.User; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; + public interface UserRepository extends JpaRepository { - + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) + @Query("select u from User u where u.id = :id") + Optional findByIdForUpdate(@Param("id") String id); } diff --git a/src/main/java/com/study/course_registration/seeder/DataSeeder.java b/src/main/java/com/study/course_registration/seeder/DataSeeder.java index 92310bf..28caa94 100644 --- a/src/main/java/com/study/course_registration/seeder/DataSeeder.java +++ b/src/main/java/com/study/course_registration/seeder/DataSeeder.java @@ -1,91 +1,121 @@ package com.study.course_registration.seeder; -import org.springframework.context.annotation.Profile; +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.study.course_registration.entity.Lesson; +import com.study.course_registration.entity.LessonSchedule; import com.study.course_registration.entity.Professor; +import com.study.course_registration.entity.Semester; import com.study.course_registration.entity.Subject; import com.study.course_registration.entity.User; -import com.study.course_registration.entity.UserLesson; import com.study.course_registration.enums.UserRole; import com.study.course_registration.repository.LessonRepository; -import com.study.course_registration.repository.UserRepository; -import com.study.course_registration.repository.SubjectRepository; +import com.study.course_registration.repository.LessonScheduleRepository; import com.study.course_registration.repository.ProfessorRepository; -import com.study.course_registration.repository.UserLessonRepository; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.temporal.ChronoUnit; -import java.util.List; - -import org.springframework.boot.CommandLineRunner; +import com.study.course_registration.repository.SemesterRepository; +import com.study.course_registration.repository.SubjectRepository; +import com.study.course_registration.repository.UserRepository; @Component -@Profile ("local") +@ConditionalOnProperty(name = "app.seed.enabled", havingValue = "true") public class DataSeeder implements CommandLineRunner { + public static final String SEMESTER_ID = "00000000-0000-0000-0000-000000000001"; + public static final String STUDENT_GRADE_1_ID = "30000000-0000-0000-0000-000000000001"; + public static final String STUDENT_GRADE_2_ID = "30000000-0000-0000-0000-000000000002"; + public static final String POSTGRADUATE_ID = "30000000-0000-0000-0000-000000000003"; + public static final String DATA_STRUCTURES_01_ID = "40000000-0000-0000-0000-000000000001"; + public static final String DATA_STRUCTURES_02_ID = "40000000-0000-0000-0000-000000000002"; + public static final String OVERLAP_OS_ID = "40000000-0000-0000-0000-000000000003"; + public static final String CAPACITY_ONE_ID = "40000000-0000-0000-0000-000000000004"; + public static final String MIN_GRADE_ID = "40000000-0000-0000-0000-000000000005"; + public static final String POSTGRADUATE_ONLY_ID = "40000000-0000-0000-0000-000000000006"; + public static final String BOUNDARY_LESSON_ID = "40000000-0000-0000-0000-000000000007"; - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - - private final UserRepository userRepository; - private final LessonRepository lessonRepository; - private final SubjectRepository subjectRepository; + private final Clock clock; + private final SemesterRepository semesterRepository; private final ProfessorRepository professorRepository; - private final UserLessonRepository userLessonRepository; + private final SubjectRepository subjectRepository; + private final LessonRepository lessonRepository; + private final LessonScheduleRepository lessonScheduleRepository; + private final UserRepository userRepository; - public DataSeeder(UserRepository userRepository, LessonRepository lessonRepository, SubjectRepository subjectRepository, ProfessorRepository professorRepository, UserLessonRepository userLessonRepository) { - this.userRepository = userRepository; - this.lessonRepository = lessonRepository; - this.subjectRepository = subjectRepository; + public DataSeeder(Clock clock, + SemesterRepository semesterRepository, + ProfessorRepository professorRepository, + SubjectRepository subjectRepository, + LessonRepository lessonRepository, + LessonScheduleRepository lessonScheduleRepository, + UserRepository userRepository) { + this.clock = clock; + this.semesterRepository = semesterRepository; this.professorRepository = professorRepository; - this.userLessonRepository = userLessonRepository; + this.subjectRepository = subjectRepository; + this.lessonRepository = lessonRepository; + this.lessonScheduleRepository = lessonScheduleRepository; + this.userRepository = userRepository; } @Override @Transactional - public void run(String... args) throws Exception { - if(userRepository.count() > 0) { + public void run(String... args) { + if (semesterRepository.existsById(SEMESTER_ID)) { return; } + Instant now = clock.instant(); + LocalDate today = LocalDate.now(clock); + Semester semester = semesterRepository.save(new Semester( + SEMESTER_ID, "2026-1", today.minusDays(30), today.plusDays(120), + now.minusSeconds(86_400), now.plusSeconds(7 * 86_400), 18)); + List professors = professorRepository.saveAll(List.of( - new Professor("김영한"), - new Professor("최태영"), - new Professor("조재한") - )); + new Professor("10000000-0000-0000-0000-000000000001", "김영한"), + new Professor("10000000-0000-0000-0000-000000000002", "최태영"), + new Professor("10000000-0000-0000-0000-000000000003", "조재한"))); List subjects = subjectRepository.saveAll(List.of( - new Subject("자바 프로그래밍", "CS201", "자바 프로그래밍 기초"), - new Subject("자료구조", "CS202", "자료구조 및 알고리즘"), - new Subject("데이터베이스", "CS203", "데이터베이스 시스템") - )); + new Subject("20000000-0000-0000-0000-000000000001", "자바 프로그래밍", "CS201", "자바 프로그래밍 기초", 3), + new Subject("20000000-0000-0000-0000-000000000002", "자료구조", "CS202", "자료구조 및 알고리즘", 3), + new Subject("20000000-0000-0000-0000-000000000003", "데이터베이스", "CS203", "데이터베이스 시스템", 3), + new Subject("20000000-0000-0000-0000-000000000004", "컴퓨터 네트워크", "CS204", "네트워크 기초", 3), + new Subject("20000000-0000-0000-0000-000000000005", "운영체제", "CS301", "운영체제 핵심", 3), + new Subject("20000000-0000-0000-0000-000000000006", "대학원 세미나", "CS401", "대학원 전용 세미나", 3))); - Instant base = Instant.now().truncatedTo(ChronoUnit.HOURS); - List lessons = lessonRepository.saveAll(List.of( - new Lesson("자바 프로그래밍", subjects.get(0), professors.get(0), kst(3, 2, 9), kst(3, 2, 11)), - new Lesson("자료구조", subjects.get(1), professors.get(1), kst(3, 2, 13), kst(3, 2, 17)), - new Lesson("데이터베이스", subjects.get(2), professors.get(2), kst(3, 3, 10), kst(3, 3, 14)) - )); + Lesson data01 = new Lesson(DATA_STRUCTURES_01_ID, "자료구조 01분반", subjects.get(1), professors.get(0), semester, 30, null, null); + Lesson data02 = new Lesson(DATA_STRUCTURES_02_ID, "자료구조 02분반", subjects.get(1), professors.get(1), semester, 30, null, null); + Lesson os = new Lesson(OVERLAP_OS_ID, "운영체제", subjects.get(4), professors.get(1), semester, 30, null, null); + Lesson capacityOne = new Lesson(CAPACITY_ONE_ID, "데이터베이스 소수정예", subjects.get(2), professors.get(2), semester, 1, null, null); + Lesson minGrade = new Lesson(MIN_GRADE_ID, "고급 네트워크", subjects.get(3), professors.get(2), semester, 30, 3L, null); + Lesson postgraduate = new Lesson(POSTGRADUATE_ONLY_ID, "대학원 세미나", subjects.get(5), professors.get(0), semester, 20, null, UserRole.POSTGRADUATE); + Lesson boundary = new Lesson(BOUNDARY_LESSON_ID, "자바 프로그래밍", subjects.get(0), professors.get(0), semester, 30, null, null); + lessonRepository.saveAll(List.of(data01, data02, os, capacityOne, minGrade, postgraduate, boundary)); - List users = userRepository.saveAll(List.of( - new User("학생1", 1L, UserRole.STUDENT), - new User("학생2", 2L, UserRole.STUDENT), - new User("대학원생", 3L, UserRole.POSTGRADUATE) - )); + lessonScheduleRepository.saveAll(List.of( + schedule("50000000-0000-0000-0000-000000000001", data01, DayOfWeek.MONDAY, 9, 11), + schedule("50000000-0000-0000-0000-000000000002", data02, DayOfWeek.TUESDAY, 9, 11), + schedule("50000000-0000-0000-0000-000000000003", os, DayOfWeek.MONDAY, 10, 12), + schedule("50000000-0000-0000-0000-000000000004", capacityOne, DayOfWeek.WEDNESDAY, 9, 11), + schedule("50000000-0000-0000-0000-000000000005", minGrade, DayOfWeek.THURSDAY, 9, 11), + schedule("50000000-0000-0000-0000-000000000006", postgraduate, DayOfWeek.FRIDAY, 9, 11), + schedule("50000000-0000-0000-0000-000000000007", boundary, DayOfWeek.MONDAY, 11, 13))); - List userLessons = userLessonRepository.saveAll(List.of( - new UserLesson(users.get(0), lessons.get(0)), - new UserLesson(users.get(0), lessons.get(1)), - new UserLesson(users.get(1), lessons.get(1)), - new UserLesson(users.get(2), lessons.get(2)) - )); + userRepository.saveAll(List.of( + new User(STUDENT_GRADE_1_ID, "학생1", 1L, UserRole.STUDENT), + new User(STUDENT_GRADE_2_ID, "학생2", 2L, UserRole.STUDENT), + new User(POSTGRADUATE_ID, "대학원생", 3L, UserRole.POSTGRADUATE))); } - private Instant kst(int month, int day, int hour) { - return ZonedDateTime.of(2026, month, day, hour, 0, 0, 0, KST).toInstant(); + private static LessonSchedule schedule(String id, Lesson lesson, DayOfWeek day, int startHour, int endHour) { + return new LessonSchedule(id, lesson, day, LocalTime.of(startHour, 0), LocalTime.of(endHour, 0)); } - } diff --git a/src/main/java/com/study/course_registration/service/CourseRegistrationService.java b/src/main/java/com/study/course_registration/service/CourseRegistrationService.java index c3c38a3..412fa46 100644 --- a/src/main/java/com/study/course_registration/service/CourseRegistrationService.java +++ b/src/main/java/com/study/course_registration/service/CourseRegistrationService.java @@ -1,36 +1,183 @@ package com.study.course_registration.service; -import org.springframework.stereotype.Service; +import java.time.Clock; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; -import com.study.course_registration.dto.RequestDto; -import com.study.course_registration.dto.ResponseDto; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.study.course_registration.dto.ScheduleResponse; +import com.study.course_registration.dto.registration.CourseRegistrationRequest; +import com.study.course_registration.dto.registration.CourseRegistrationResponse; +import com.study.course_registration.dto.registration.RegistrationItemResponse; +import com.study.course_registration.dto.registration.RegistrationListResponse; +import com.study.course_registration.entity.Lesson; +import com.study.course_registration.entity.LessonSchedule; +import com.study.course_registration.entity.Semester; +import com.study.course_registration.entity.User; +import com.study.course_registration.entity.UserLesson; +import com.study.course_registration.enums.RegistrationErrorCode; +import com.study.course_registration.exception.RegistrationException; import com.study.course_registration.repository.LessonRepository; +import com.study.course_registration.repository.LessonScheduleRepository; +import com.study.course_registration.repository.SemesterRepository; import com.study.course_registration.repository.UserLessonRepository; import com.study.course_registration.repository.UserRepository; -import com.study.course_registration.repository.SubjectRepository; -import com.study.course_registration.repository.ProfessorRepository; +import com.study.course_registration.service.policy.RegistrationValidator; @Service public class CourseRegistrationService { + private final Clock clock; private final UserRepository userRepository; private final LessonRepository lessonRepository; - private final SubjectRepository subjectRepository; - private final ProfessorRepository professorRepository; + private final SemesterRepository semesterRepository; + private final LessonScheduleRepository lessonScheduleRepository; private final UserLessonRepository userLessonRepository; + private final RegistrationValidator validator; - public CourseRegistrationService(UserRepository userRepository, LessonRepository lessonRepository, SubjectRepository subjectRepository, ProfessorRepository professorRepository, UserLessonRepository userLessonRepository) { + public CourseRegistrationService(Clock clock, + UserRepository userRepository, + LessonRepository lessonRepository, + SemesterRepository semesterRepository, + LessonScheduleRepository lessonScheduleRepository, + UserLessonRepository userLessonRepository, + RegistrationValidator validator) { + this.clock = clock; this.userRepository = userRepository; this.lessonRepository = lessonRepository; - this.subjectRepository = subjectRepository; - this.professorRepository = professorRepository; + this.semesterRepository = semesterRepository; + this.lessonScheduleRepository = lessonScheduleRepository; this.userLessonRepository = userLessonRepository; + this.validator = validator; } - public ResponseDto registerCourse(RequestDto requestDto) { - - return new ResponseDto(); + @Transactional + public CourseRegistrationResponse register(String userId, CourseRegistrationRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.")); + Lesson lesson = lessonRepository.findById(request.lessonId()) + .orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다.")); + + var now = clock.instant(); + validator.validatePreconditions(user, lesson, now); + + // Lock order is a system invariant: User -> Lesson. + User lockedUser = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.")); + Lesson lockedLesson = lessonRepository.findByIdForUpdate(request.lessonId()) + .orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다.")); + + List activeRegistrations = userLessonRepository.findActiveByUserIdAndSemesterId( + lockedUser.getId(), lockedLesson.getSemester().getId()); + Map> 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 activeRegistrations = userLessonRepository.findActiveByUserIdAndSemesterId(userId, semester.getId()); + + Set lessonIds = activeRegistrations.stream() + .map(UserLesson::getLesson) + .map(Lesson::getId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map> schedulesByLesson = groupSchedules( + lessonIds.isEmpty() ? List.of() : lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds)); + + List items = activeRegistrations.stream() + .map(registration -> { + Lesson lesson = registration.getLesson(); + List 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> loadSchedules(List registrations, Lesson candidate) { + Set 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> groupSchedules(List 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 -// 검증 로직 -// \ No newline at end of file diff --git a/src/main/java/com/study/course_registration/service/LessonQueryService.java b/src/main/java/com/study/course_registration/service/LessonQueryService.java new file mode 100644 index 0000000..2174864 --- /dev/null +++ b/src/main/java/com/study/course_registration/service/LessonQueryService.java @@ -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 lessonPage = subjectId == null || subjectId.isBlank() + ? lessonRepository.findBySemester_Id(semester.getId(), pageable) + : lessonRepository.findBySemester_IdAndSubject_Id(semester.getId(), subjectId, pageable); + + List lessonIds = lessonPage.getContent().stream().map(Lesson::getId).toList(); + Map> schedules = loadSchedules(lessonIds); + Map counts = loadCounts(lessonIds); + + List 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 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 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> loadSchedules(List 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 loadCounts(List 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); + } +} diff --git a/src/main/java/com/study/course_registration/service/policy/RegistrationValidator.java b/src/main/java/com/study/course_registration/service/policy/RegistrationValidator.java new file mode 100644 index 0000000..deb1726 --- /dev/null +++ b/src/main/java/com/study/course_registration/service/policy/RegistrationValidator.java @@ -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 activeRegistrations, + Map> 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 candidateSchedules = schedulesByLessonId.getOrDefault(lesson.getId(), List.of()); + for (UserLesson registration : activeRegistrations) { + List 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 activeRegistrations) { + return currentCredits(activeRegistrations) + lesson.getSubject().getCredit(); + } + + public int currentCredits(List 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); + } +} diff --git a/src/main/java/com/study/course_registration/service/policy/ScheduleConflictChecker.java b/src/main/java/com/study/course_registration/service/policy/ScheduleConflictChecker.java new file mode 100644 index 0000000..91f25be --- /dev/null +++ b/src/main/java/com/study/course_registration/service/policy/ScheduleConflictChecker.java @@ -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()); + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index e69de29..66ea8b5 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -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} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index a2e3fcf..8d8b910 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -1,21 +1,25 @@ spring: - datasource: - url: jdbc:h2:mem:course_db - driver-class-name: org.h2.Driver - username: sa - password: - jpa: - hibernate: - ddl-auto: create-drop - show-sql: true - properties: - hibernate: - format_sql: true - h2: - console: - enabled: true - path: /h2-console + datasource: + url: jdbc:h2:mem:course_db;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;LOCK_TIMEOUT=3000 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + h2: + console: + enabled: true + path: /h2-console + +app: + seed: + enabled: true logging: - level: - org.hibernate.SQL: debug + level: + org.hibernate.SQL: debug diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index e69de29..2aa64d7 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -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} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d149947..d423891 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,28 +1,30 @@ server: - application: - name: ${APP_NAME:course-registration} - profiles: - active: ${APP_PROFILE:local} + address: ${SERVER_ADDRESS:127.0.0.1} + port: ${SERVER_PORT:8080} spring: - jackson: - time-zone: UTC - h2: - console: - enabled: true - path: /h2-console - datasource: - url: jdbc:h2:mem:course_db - username: sa - password: - driver-class-name: org.h2.Driver - jpa: - hibernate: - ddl-auto: create - show-sql: true - properties: - hibernate: - format_sql: true + application: + name: ${APP_NAME:course-registration} + profiles: + default: local + jackson: + time-zone: UTC + jpa: + open-in-view: false -logging.level: - org.hibernate.SQL: debug +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + path: /swagger-ui.html + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + probes: + enabled: true diff --git a/src/test/java/com/study/course_registration/service/CourseRegistrationConcurrencyIntegrationTest.java b/src/test/java/com/study/course_registration/service/CourseRegistrationConcurrencyIntegrationTest.java new file mode 100644 index 0000000..4cb9d17 --- /dev/null +++ b/src/test/java/com/study/course_registration/service/CourseRegistrationConcurrencyIntegrationTest.java @@ -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 users = users(10, "cap1-user-"); + List results = runConcurrent(users.stream() + .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 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 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 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 users = users(50, "cap30-user-"); + List results = runConcurrent(users.stream() + .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 users(int count, String prefix) { + List 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 runConcurrent(List 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 results = Collections.synchronizedList(new ArrayList<>()); + List 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; + } +} diff --git a/src/test/java/com/study/course_registration/service/CourseRegistrationServiceIntegrationTest.java b/src/test/java/com/study/course_registration/service/CourseRegistrationServiceIntegrationTest.java new file mode 100644 index 0000000..a973e9c --- /dev/null +++ b/src/test/java/com/study/course_registration/service/CourseRegistrationServiceIntegrationTest.java @@ -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()); + } +} diff --git a/src/test/java/com/study/course_registration/service/LessonQueryServiceIntegrationTest.java b/src/test/java/com/study/course_registration/service/LessonQueryServiceIntegrationTest.java new file mode 100644 index 0000000..b23812f --- /dev/null +++ b/src/test/java/com/study/course_registration/service/LessonQueryServiceIntegrationTest.java @@ -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)); + } +} diff --git a/src/test/java/com/study/course_registration/service/policy/RegistrationValidatorTest.java b/src/test/java/com/study/course_registration/service/policy/RegistrationValidatorTest.java new file mode 100644 index 0000000..7fad9fa --- /dev/null +++ b/src/test/java/com/study/course_registration/service/policy/RegistrationValidatorTest.java @@ -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)); + } +} diff --git a/src/test/java/com/study/course_registration/service/policy/ScheduleConflictCheckerTest.java b/src/test/java/com/study/course_registration/service/policy/ScheduleConflictCheckerTest.java new file mode 100644 index 0000000..550d841 --- /dev/null +++ b/src/test/java/com/study/course_registration/service/policy/ScheduleConflictCheckerTest.java @@ -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(); + } +} diff --git a/src/test/java/com/study/course_registration/service/policy/TestFixtures.java b/src/test/java/com/study/course_registration/service/policy/TestFixtures.java new file mode 100644 index 0000000..785b945 --- /dev/null +++ b/src/test/java/com/study/course_registration/service/policy/TestFixtures.java @@ -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)); + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..b47c982 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -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