Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d0abcf2
docs: 수강 신청 기능 목록 정리
username0w Dec 12, 2025
ec941aa
feat: startDate/endDate 검증 구현
username0w Dec 12, 2025
5f35548
feat: 생성 시 기본 상태 PREPARING 설정
username0w Dec 12, 2025
b4b63b8
feat: 유료/무료 구분 및 최대 수강인원 검증 구현
username0w Dec 12, 2025
61d5b30
feat: 유료 강의 fee 필드 및 검증 추가
username0w Dec 12, 2025
0ed117d
feat: 상태 기반 수강 신청 가능 여부 구현
username0w Dec 12, 2025
5a09362
feat: 유료 강의 최대 수강 인원 초과 시 수강 불가 검증 추가
username0w Dec 12, 2025
f67bd43
feat: 수강 신청 기능 구현
username0w Dec 12, 2025
5901d8f
feat: SessionImage 생성 및 필수 검증 추가
username0w Dec 12, 2025
bce1fbd
test: SessionTestBuilder 추가 및 테스트에 적용
username0w Dec 12, 2025
3d04dca
feat: Session에 SessionImage 필드 추가 및 생성자 검증
username0w Dec 12, 2025
3d8f3a0
feat: 수강 기간, 수강 정원, 수강료 정보를 담는 VO 추가
username0w Dec 12, 2025
1de7c10
refactor: Session에 Period, Capacity, SessionPricing 적용
username0w Dec 13, 2025
3151975
feat: Session 관련 불변 정보 SessionInfo 추가
username0w Dec 13, 2025
35191a7
feat: EnrollmentPolicy 인터페이스 및 구현체 추가
username0w Dec 15, 2025
8340a2c
feat: SessionPricing을 Session으로 이동 and SessionInfo 필드 일부 외부 추출, Enrol…
username0w Dec 15, 2025
e834e6d
feat: ImageDimension 추가
username0w Dec 15, 2025
bbbc5b7
feat: ImageSize 추가
username0w Dec 15, 2025
23c29f0
feat: ImageSize, ImageDimension 적용
username0w Dec 15, 2025
61fd7b1
feat: FileName 추가
username0w Dec 15, 2025
cc4028c
feat: FileName 적용
username0w Dec 15, 2025
3d841cf
feat: Enrollment 추가
username0w Dec 15, 2025
9b6e440
feat: Enrollment, Enrollments 추가
username0w Dec 15, 2025
7766878
feat: Enrollment, Enrollments 적용, Capacity unlimited 필드 추가
username0w Dec 16, 2025
6e6c5fd
feat: 사용하지 않는 EnrollmentPolicy 제거
username0w Dec 16, 2025
ab7d29e
feat: 정적 팩토리 도입으로 불가능한 도메인 상태 생성 방지
username0w Dec 16, 2025
d4b5dd9
feat: Pricing과 Payment 비교 로직 이동
username0w Dec 16, 2025
61df420
refactor: Stream 사용, 예외 메시지 상수화
username0w Dec 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# 학습 관리 시스템(Learning Management System)

## 진행 방법

* 학습 관리 시스템의 수강신청 요구사항을 파악한다.
* 요구사항에 대한 구현을 완료한 후 자신의 github 아이디에 해당하는 브랜치에 Pull Request(이하 PR)를 통해 코드 리뷰 요청을 한다.
* 코드 리뷰 피드백에 대한 개선 작업을 하고 다시 PUSH한다.
* 모든 피드백을 완료하면 다음 단계를 도전하고 앞의 과정을 반복한다.

## 온라인 코드 리뷰 과정

* [텍스트와 이미지로 살펴보는 온라인 코드 리뷰 과정](https://github.com/next-step/nextstep-docs/tree/master/codereview)

---
Expand All @@ -14,10 +17,46 @@

### 질문 삭제하기

- 질문자 = 로그인 사용자 아니면 삭제 불가
- 답변 중 다른 사람이 쓴 답변이 있으면 삭제 불가
- 답변이 없으면 삭제 가능
- 질문자와 답변자가 모두 동일하면 삭제 가능
- 삭제되면 질문 상태 변경
- 삭제되면 모든 답변 상태 변경
- 삭제 이력 생성됨
- 질문자 = 로그인 사용자 아니면 삭제 불가
- 답변 중 다른 사람이 쓴 답변이 있으면 삭제 불가
- 답변이 없으면 삭제 가능
- 질문자와 답변자가 모두 동일하면 삭제 가능
- 삭제되면 질문 상태 변경
- 삭제되면 모든 답변 상태 변경
- 삭제 이력 생성됨

### 수강 신청 기능

#### Course

- 기수 단위로 운영
- 여러 개의 Session(강의)을 가질 수 있음

#### Session (강의)

##### 속성

- 시작일(start_date), 종료일(end_date)
- 커버 이미지
- 최대 1MB
- 타입: gif, jpg/jpeg, png, svg
- 최소 사이즈: width 300px, height 200px
- 비율: 3:2
- 무료/유료 구분
- 최대 수강 인원 (유료 강의만 적용)
- 상태: 준비중, 모집중, 종료

##### 규칙

- 강의 상태가 `모집중`일 때만 수강 신청 가능
- 무료 강의는 수강 인원 제한 없음
- 유료 강의
- 최대 수강 인원을 초과할 수 없음
- 결제 금액과 수강료가 일치해야 수강 신청 가능
- 결제 정보는 `payments` 모듈의 Payment 객체를 통해 관리

#### Payment (결제)

- 유료 강의 결제 정보 관리
- Payment 객체 반환
- 결제 완료 여부 확인 후 수강 신청 가능
33 changes: 33 additions & 0 deletions src/main/java/nextstep/payments/domain/Payment.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package nextstep.payments.domain;

import java.time.LocalDateTime;
import java.util.Objects;
import nextstep.users.domain.NsUser;

public class Payment {
private String id;
Expand All @@ -26,4 +28,35 @@ public Payment(String id, Long sessionId, Long nsUserId, Long amount) {
this.amount = amount;
this.createdAt = LocalDateTime.now();
}

public Long nsUserId() {
return nsUserId;
}

public boolean isPaidBy(NsUser user) {
return user.getId().equals(this.nsUserId);
}

public boolean isPaidFor(int fee) {
return this.amount == fee;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Payment payment = (Payment) o;
return Objects.equals(id, payment.id) && Objects.equals(sessionId, payment.sessionId)
&& Objects.equals(nsUserId, payment.nsUserId) && Objects.equals(amount, payment.amount)
&& Objects.equals(createdAt, payment.createdAt);
}

@Override
public int hashCode() {
return Objects.hash(id, sessionId, nsUserId, amount, createdAt);
}
}
87 changes: 87 additions & 0 deletions src/main/java/nextstep/sessions/domain/Capacity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package nextstep.sessions.domain;

public class Capacity {

private static final String ERROR_MAX_CAPACITY_REQUIRED = "유료 강의는 최대 수강인원이 있어야 합니다";
private static final String ERROR_ENROLL_COUNT_NEGATIVE = "수강 인원은 0 이상이어야 합니다";
private static final String ERROR_ENROLL_COUNT_EXCEED = "수강 인원은 수강 정원을 초과할 수 없습니다";
private static final String ERROR_CANNOT_ENROLL = "수강 신청을 할 수 없습니다";

private static final int DEFAULT_ENROLL_COUNT = 0;

private final Integer maxCapacity;
private final boolean unlimited;
private final int enrollCount;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

db가 아닌 도메인 객체인 만큼 수강생 목록을 가지는 것은 어떨까?
db를 고려해 구현한 것은 아닐까?



Capacity(Integer maxCapacity, boolean unlimited) {
this(maxCapacity, unlimited, DEFAULT_ENROLL_COUNT);
}

Capacity(Integer maxCapacity, boolean unlimited, int enrollCount) {
validateMaxCapacity(maxCapacity);
validateEnrollCount(enrollCount);
validateEnrollCountWithinCapacity(maxCapacity, enrollCount);

this.maxCapacity = maxCapacity;
this.unlimited = unlimited;
this.enrollCount = enrollCount;
}

public static Capacity limited(int maxCapacity) {
return new Capacity(maxCapacity, false, DEFAULT_ENROLL_COUNT);
}

public static Capacity unlimited() {
return new Capacity(Integer.MAX_VALUE, true, 0);
}

public Integer maxCapacity() {
return maxCapacity;
}

public int enrollCount() {
return enrollCount;
}

public boolean canEnroll() {
return unlimited || enrollCount < maxCapacity;
}

public boolean isUnlimited() {
return unlimited;
}

public boolean isFull() {
return enrollCount >= maxCapacity;
}

public boolean hasAvailableSeat() {
return !isFull();
}

public Capacity increaseEnrollCount() {
if (!canEnroll()) {
throw new IllegalArgumentException(ERROR_CANNOT_ENROLL);
}
return new Capacity(maxCapacity, unlimited, enrollCount + 1);
}

private void validateMaxCapacity(Integer maxCapacity) {
if (!unlimited && (maxCapacity == null || maxCapacity <= 0)) {
throw new IllegalArgumentException(ERROR_MAX_CAPACITY_REQUIRED);
}
}

private static void validateEnrollCount(int enrollCount) {
if (enrollCount < 0) {
throw new IllegalArgumentException(ERROR_ENROLL_COUNT_NEGATIVE);
}
}

private static void validateEnrollCountWithinCapacity(Integer maxCapacity, int enrollCount) {
if (maxCapacity != null && enrollCount > maxCapacity) {
throw new IllegalArgumentException(ERROR_ENROLL_COUNT_EXCEED);
}
}
}
56 changes: 56 additions & 0 deletions src/main/java/nextstep/sessions/domain/Enrollment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package nextstep.sessions.domain;

import java.time.LocalDateTime;
import java.util.Objects;
import nextstep.payments.domain.Payment;
import nextstep.users.domain.NsUser;

public class Enrollment {

static final String ERROR_USER_PAYMENT_MISMATCH = "결제한 사용자와 신청자가 일치하지 않습니다";

private final NsUser user;
private final Payment payment;
private final LocalDateTime enrolledAt;

public Enrollment(NsUser user, Payment payment) {
validateUserPayMatch(user, payment);
this.user = user;
this.payment = payment;
this.enrolledAt = LocalDateTime.now();
}

public Payment payment() {
return payment;
}

public boolean canPayFor(SessionPricing pricing) {
return !pricing.isPaid() || payment.isPaidFor(pricing.fee());
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Enrollment that = (Enrollment) o;
return Objects.equals(user, that.user) && Objects.equals(payment, that.payment)
&& Objects.equals(enrolledAt, that.enrolledAt);
}

@Override
public int hashCode() {
return Objects.hash(user, payment, enrolledAt);
}

private static void validateUserPayMatch(NsUser user, Payment payment) {
if (payment.isPaidBy(user)) {
return;
}
throw new IllegalArgumentException(ERROR_USER_PAYMENT_MISMATCH);
}

}
23 changes: 23 additions & 0 deletions src/main/java/nextstep/sessions/domain/Enrollments.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package nextstep.sessions.domain;

import java.util.HashSet;
import java.util.Set;

public class Enrollments {

static final String ERROR_ALREADY_ENROLLED = "이미 등록된 사용자입니다.";

private final Set<Enrollment> enrollments = new HashSet<>();

public void add(Enrollment enrollment) {
if (enrollments.contains(enrollment)) {
throw new IllegalArgumentException(ERROR_ALREADY_ENROLLED);
}
enrollments.add(enrollment);
}

public int size() {
return enrollments.size();
}
}

27 changes: 27 additions & 0 deletions src/main/java/nextstep/sessions/domain/FileName.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package nextstep.sessions.domain;

public class FileName {

static final String ERROR_FILENAME_NOT_EMPTY = "파일명은 빈 값일 수 없습니다";
private final String value;

public FileName(String value) {
validate(value);
this.value = value;
}

public String value() {
return value;
}

public String extension() {
return value.substring(value.lastIndexOf('.') + 1);
}

private void validate(String fileName) {
if (fileName == null || fileName.trim().isEmpty()) {
throw new IllegalArgumentException(ERROR_FILENAME_NOT_EMPTY);
}
}

}
29 changes: 29 additions & 0 deletions src/main/java/nextstep/sessions/domain/ImageDimension.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package nextstep.sessions.domain;

public class ImageDimension {

private static final int MIN_WIDTH = 300;
private static final int MIN_HEIGHT = 200;

private static final String ERROR_MIN_SIZE = "이미지 크기가 최소 조건을 만족하지 않습니다";
private static final String ERROR_RATIO = "이미지 비율은 3:2여야 합니다";

private final int width;
private final int height;

public ImageDimension(int width, int height) {
validate(width, height);
this.width = width;
this.height = height;
}

private void validate(int width, int height) {
if (width < MIN_WIDTH || height < MIN_HEIGHT) {
throw new IllegalArgumentException(ERROR_MIN_SIZE);
}
if (width * 2 != height * 3) {
throw new IllegalArgumentException(ERROR_RATIO);
}
}

}
24 changes: 24 additions & 0 deletions src/main/java/nextstep/sessions/domain/ImageSize.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package nextstep.sessions.domain;

public class ImageSize {

static final String ERROR_IMAGE_SIZE = "이미지 용량은 1MB 이하여야 합니다";
private static final long MAX_SIZE = 1_000_000;

private final long size;

public ImageSize(long size) {
validate(size);
this.size = size;
}

private static void validate(long size) {
if (size <= 0 || size > MAX_SIZE) {
throw new IllegalArgumentException(ERROR_IMAGE_SIZE);
}
}

public long value() {
return size;
}
}
22 changes: 22 additions & 0 deletions src/main/java/nextstep/sessions/domain/ImageType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package nextstep.sessions.domain;

import java.util.Arrays;

public enum ImageType {
GIF, JPG, JPEG, PNG, SVG;

private static final String ERROR_EMPTY_EXT = "확장자가 유효하지 않습니다";
private static final String ERROR_UNSUPPORTED_EXT = "지원하지 않는 이미지 형식입니다: ";

public static ImageType from(String ext) {
if (ext == null || ext.trim().isEmpty()) {
throw new IllegalArgumentException(ERROR_EMPTY_EXT);
}

return Arrays.stream(ImageType.values())
.filter(type -> type.name().equalsIgnoreCase(ext))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(ERROR_UNSUPPORTED_EXT + ext));
}
}

Loading