Skip to content

Commit 0838677

Browse files
authored
2단계 - 수강신청(도메인 모델) (#818)
* docs: 수강 신청 기능 목록 정리 * feat: startDate/endDate 검증 구현 * feat: 생성 시 기본 상태 PREPARING 설정 * feat: 유료/무료 구분 및 최대 수강인원 검증 구현 * feat: 유료 강의 fee 필드 및 검증 추가 * feat: 상태 기반 수강 신청 가능 여부 구현 * feat: 유료 강의 최대 수강 인원 초과 시 수강 불가 검증 추가 * feat: 수강 신청 기능 구현 * feat: SessionImage 생성 및 필수 검증 추가 * test: SessionTestBuilder 추가 및 테스트에 적용 * feat: Session에 SessionImage 필드 추가 및 생성자 검증 * feat: 수강 기간, 수강 정원, 수강료 정보를 담는 VO 추가 * refactor: Session에 Period, Capacity, SessionPricing 적용 * feat: Session 관련 불변 정보 SessionInfo 추가 * feat: EnrollmentPolicy 인터페이스 및 구현체 추가 * feat: SessionPricing을 Session으로 이동 and SessionInfo 필드 일부 외부 추출, EnrollmentPolicy 추가 * feat: ImageDimension 추가 * feat: ImageSize 추가 * feat: ImageSize, ImageDimension 적용 * feat: FileName 추가 * feat: FileName 적용 * feat: Enrollment 추가 * feat: Enrollment, Enrollments 추가 * feat: Enrollment, Enrollments 적용, Capacity unlimited 필드 추가 * feat: 사용하지 않는 EnrollmentPolicy 제거 * feat: 정적 팩토리 도입으로 불가능한 도메인 상태 생성 방지 * feat: Pricing과 Payment 비교 로직 이동 * refactor: Stream 사용, 예외 메시지 상수화 * refactor: Capacity에서 수강 인원 상태 제거 * refactor: 수강신청 로직을 SessionEnrollment 객체로 분리 * refactor: sessions 패키지 분리
1 parent d900719 commit 0838677

30 files changed

+982
-10
lines changed

README.md

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
# 학습 관리 시스템(Learning Management System)
2+
23
## 진행 방법
4+
35
* 학습 관리 시스템의 수강신청 요구사항을 파악한다.
46
* 요구사항에 대한 구현을 완료한 후 자신의 github 아이디에 해당하는 브랜치에 Pull Request(이하 PR)를 통해 코드 리뷰 요청을 한다.
57
* 코드 리뷰 피드백에 대한 개선 작업을 하고 다시 PUSH한다.
68
* 모든 피드백을 완료하면 다음 단계를 도전하고 앞의 과정을 반복한다.
79

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

1114
---
@@ -14,10 +17,46 @@
1417

1518
### 질문 삭제하기
1619

17-
- 질문자 = 로그인 사용자 아니면 삭제 불가
18-
- 답변 중 다른 사람이 쓴 답변이 있으면 삭제 불가
19-
- 답변이 없으면 삭제 가능
20-
- 질문자와 답변자가 모두 동일하면 삭제 가능
21-
- 삭제되면 질문 상태 변경
22-
- 삭제되면 모든 답변 상태 변경
23-
- 삭제 이력 생성됨
20+
- 질문자 = 로그인 사용자 아니면 삭제 불가
21+
- 답변 중 다른 사람이 쓴 답변이 있으면 삭제 불가
22+
- 답변이 없으면 삭제 가능
23+
- 질문자와 답변자가 모두 동일하면 삭제 가능
24+
- 삭제되면 질문 상태 변경
25+
- 삭제되면 모든 답변 상태 변경
26+
- 삭제 이력 생성됨
27+
28+
### 수강 신청 기능
29+
30+
#### Course
31+
32+
- 기수 단위로 운영
33+
- 여러 개의 Session(강의)을 가질 수 있음
34+
35+
#### Session (강의)
36+
37+
##### 속성
38+
39+
- 시작일(start_date), 종료일(end_date)
40+
- 커버 이미지
41+
- 최대 1MB
42+
- 타입: gif, jpg/jpeg, png, svg
43+
- 최소 사이즈: width 300px, height 200px
44+
- 비율: 3:2
45+
- 무료/유료 구분
46+
- 최대 수강 인원 (유료 강의만 적용)
47+
- 상태: 준비중, 모집중, 종료
48+
49+
##### 규칙
50+
51+
- 강의 상태가 `모집중`일 때만 수강 신청 가능
52+
- 무료 강의는 수강 인원 제한 없음
53+
- 유료 강의
54+
- 최대 수강 인원을 초과할 수 없음
55+
- 결제 금액과 수강료가 일치해야 수강 신청 가능
56+
- 결제 정보는 `payments` 모듈의 Payment 객체를 통해 관리
57+
58+
#### Payment (결제)
59+
60+
- 유료 강의 결제 정보 관리
61+
- Payment 객체 반환
62+
- 결제 완료 여부 확인 후 수강 신청 가능

src/main/java/nextstep/payments/domain/Payment.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package nextstep.payments.domain;
22

33
import java.time.LocalDateTime;
4+
import java.util.Objects;
5+
import nextstep.users.domain.NsUser;
46

57
public class Payment {
68
private String id;
@@ -26,4 +28,35 @@ public Payment(String id, Long sessionId, Long nsUserId, Long amount) {
2628
this.amount = amount;
2729
this.createdAt = LocalDateTime.now();
2830
}
31+
32+
public Long nsUserId() {
33+
return nsUserId;
34+
}
35+
36+
public boolean isPaidBy(NsUser user) {
37+
return user.getId().equals(this.nsUserId);
38+
}
39+
40+
public boolean isPaidFor(int fee) {
41+
return this.amount == fee;
42+
}
43+
44+
@Override
45+
public boolean equals(Object o) {
46+
if (this == o) {
47+
return true;
48+
}
49+
if (o == null || getClass() != o.getClass()) {
50+
return false;
51+
}
52+
Payment payment = (Payment) o;
53+
return Objects.equals(id, payment.id) && Objects.equals(sessionId, payment.sessionId)
54+
&& Objects.equals(nsUserId, payment.nsUserId) && Objects.equals(amount, payment.amount)
55+
&& Objects.equals(createdAt, payment.createdAt);
56+
}
57+
58+
@Override
59+
public int hashCode() {
60+
return Objects.hash(id, sessionId, nsUserId, amount, createdAt);
61+
}
2962
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package nextstep.sessions.domain;
2+
3+
import java.time.LocalDate;
4+
5+
public class Period {
6+
7+
private static final String ERROR_INVALID_DATE = "시작일이 종료일보다 빨라야 합니다";
8+
9+
private final LocalDate startDate;
10+
11+
private final LocalDate endDate;
12+
13+
public Period(LocalDate startDate, LocalDate endDate) {
14+
validateDate(startDate, endDate);
15+
this.startDate = startDate;
16+
this.endDate = endDate;
17+
}
18+
19+
private void validateDate(LocalDate startDate, LocalDate endDate) {
20+
if (startDate.isAfter(endDate)) {
21+
throw new IllegalArgumentException(ERROR_INVALID_DATE);
22+
}
23+
}
24+
25+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package nextstep.sessions.domain;
2+
3+
import java.time.LocalDate;
4+
import nextstep.sessions.domain.enrollment.Enrollment;
5+
import nextstep.sessions.domain.enrollment.SessionEnrollment;
6+
import nextstep.sessions.domain.image.SessionImage;
7+
8+
public class Session {
9+
10+
public static final String ERROR_SESSION_NOT_OPEN = "모집중인 강의만 수강 신청 가능합니다.";
11+
public static final String ERROR_CAPACITY_EXCEEDED = "강의 정원이 초과되어 수강 신청할 수 없습니다.";
12+
public static final String ERROR_PAYMENT_AMOUNT_MISMATCH = "결제 금액이 강의 수강료와 일치하지 않습니다.";
13+
14+
private Long id;
15+
16+
private SessionInfo sessionInfo;
17+
18+
private SessionEnrollment sessionEnrollment;
19+
20+
Session(Long id, SessionInfo sessionInfo, SessionEnrollment sessionEnrollment) {
21+
this.id = id;
22+
this.sessionInfo = sessionInfo;
23+
this.sessionEnrollment = sessionEnrollment;
24+
}
25+
26+
public static Session paidLimited(Long id, LocalDate startDate, LocalDate endDate, int fee, int maxCapacity,
27+
SessionImage image) {
28+
SessionInfo info = new SessionInfo(new Period(startDate, endDate), image);
29+
SessionEnrollment enrollment = SessionEnrollment.paidLimited(fee, maxCapacity);
30+
return new Session(id, info, enrollment);
31+
}
32+
33+
public static Session freeUnlimited(Long id, LocalDate startDate, LocalDate endDate, SessionImage image) {
34+
SessionInfo info = new SessionInfo(new Period(startDate, endDate), image);
35+
SessionEnrollment enrollment = SessionEnrollment.freeUnlimited();
36+
return new Session(id, info, enrollment);
37+
}
38+
39+
public SessionStatus status() {
40+
return sessionEnrollment.status();
41+
}
42+
43+
public void startRecruiting() {
44+
sessionEnrollment.startRecruiting();
45+
}
46+
47+
public void enroll(Enrollment enrollment) {
48+
sessionEnrollment.enroll(enrollment);
49+
}
50+
51+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package nextstep.sessions.domain;
2+
3+
import nextstep.sessions.domain.image.SessionImage;
4+
5+
public class SessionInfo {
6+
7+
static final String ERROR_COVER_IMAGE_REQUIRED = "강의 커버 이미지는 필수입니다";
8+
9+
private final Period period;
10+
11+
private SessionImage image;
12+
13+
public SessionInfo(Period period, SessionImage image) {
14+
validateImage(image);
15+
this.period = period;
16+
this.image = image;
17+
}
18+
19+
private static void validateImage(SessionImage image) {
20+
if (image == null) {
21+
throw new IllegalArgumentException(ERROR_COVER_IMAGE_REQUIRED);
22+
}
23+
}
24+
25+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package nextstep.sessions.domain;
2+
3+
public class SessionPricing {
4+
5+
private static final String ERROR_PAID_FEE = "유료 강의는 0원 초과 여야 합니다";
6+
private static final String ERROR_FREE_FEE = "무료 강의는 0원 이어야 합니다";
7+
8+
private final boolean isPaid;
9+
private final int fee;
10+
11+
SessionPricing(boolean isPaid, int fee) {
12+
validateFee(isPaid, fee);
13+
this.isPaid = isPaid;
14+
this.fee = fee;
15+
}
16+
17+
public static SessionPricing paid(int fee) {
18+
return new SessionPricing(true, fee);
19+
}
20+
21+
public static SessionPricing free() {
22+
return new SessionPricing(false, 0);
23+
}
24+
25+
public boolean isPaid() {
26+
return isPaid;
27+
}
28+
29+
public int fee() {
30+
return fee;
31+
}
32+
33+
private void validateFee(boolean isPaid, int fee) {
34+
if (isPaid && fee <= 0) {
35+
throw new IllegalArgumentException(ERROR_PAID_FEE);
36+
}
37+
if (!isPaid && fee != 0) {
38+
throw new IllegalArgumentException(ERROR_FREE_FEE);
39+
}
40+
}
41+
42+
}
43+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package nextstep.sessions.domain;
2+
3+
public enum SessionStatus {
4+
PREPARING,
5+
OPEN,
6+
CLOSED
7+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package nextstep.sessions.domain.enrollment;
2+
3+
public class Capacity {
4+
5+
private static final String ERROR_MAX_CAPACITY_REQUIRED = "유료 강의는 최대 수강인원이 있어야 합니다";
6+
7+
private final Integer maxCapacity;
8+
private final boolean unlimited;
9+
10+
Capacity(Integer maxCapacity, boolean unlimited) {
11+
validateMaxCapacity(maxCapacity);
12+
this.maxCapacity = maxCapacity;
13+
this.unlimited = unlimited;
14+
}
15+
16+
public static Capacity limited(int maxCapacity) {
17+
return new Capacity(maxCapacity, false);
18+
}
19+
20+
public static Capacity unlimited() {
21+
return new Capacity(Integer.MAX_VALUE, true);
22+
}
23+
24+
public Integer maxCapacity() {
25+
return maxCapacity;
26+
}
27+
28+
public boolean canEnroll(int enrollmentsSize) {
29+
return unlimited || enrollmentsSize < maxCapacity;
30+
}
31+
32+
public boolean isUnlimited() {
33+
return unlimited;
34+
}
35+
36+
public boolean isFull(int enrollmentsSize) {
37+
return enrollmentsSize >= maxCapacity;
38+
}
39+
40+
private void validateMaxCapacity(Integer maxCapacity) {
41+
if (!unlimited && (maxCapacity == null || maxCapacity <= 0)) {
42+
throw new IllegalArgumentException(ERROR_MAX_CAPACITY_REQUIRED);
43+
}
44+
}
45+
46+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package nextstep.sessions.domain.enrollment;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.Objects;
5+
import nextstep.payments.domain.Payment;
6+
import nextstep.sessions.domain.SessionPricing;
7+
import nextstep.users.domain.NsUser;
8+
9+
public class Enrollment {
10+
11+
static final String ERROR_USER_PAYMENT_MISMATCH = "결제한 사용자와 신청자가 일치하지 않습니다";
12+
13+
private final NsUser user;
14+
private final Payment payment;
15+
private final LocalDateTime enrolledAt;
16+
17+
public Enrollment(NsUser user, Payment payment) {
18+
validateUserPayMatch(user, payment);
19+
this.user = user;
20+
this.payment = payment;
21+
this.enrolledAt = LocalDateTime.now();
22+
}
23+
24+
public Payment payment() {
25+
return payment;
26+
}
27+
28+
public boolean canPayFor(SessionPricing pricing) {
29+
return !pricing.isPaid() || payment.isPaidFor(pricing.fee());
30+
}
31+
32+
@Override
33+
public boolean equals(Object o) {
34+
if (this == o) {
35+
return true;
36+
}
37+
if (o == null || getClass() != o.getClass()) {
38+
return false;
39+
}
40+
Enrollment that = (Enrollment) o;
41+
return Objects.equals(user, that.user) && Objects.equals(payment, that.payment)
42+
&& Objects.equals(enrolledAt, that.enrolledAt);
43+
}
44+
45+
@Override
46+
public int hashCode() {
47+
return Objects.hash(user, payment, enrolledAt);
48+
}
49+
50+
private static void validateUserPayMatch(NsUser user, Payment payment) {
51+
if (payment.isPaidBy(user)) {
52+
return;
53+
}
54+
throw new IllegalArgumentException(ERROR_USER_PAYMENT_MISMATCH);
55+
}
56+
57+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package nextstep.sessions.domain.enrollment;
2+
3+
import java.util.HashSet;
4+
import java.util.Set;
5+
6+
public class Enrollments {
7+
8+
static final String ERROR_ALREADY_ENROLLED = "이미 등록된 사용자입니다.";
9+
10+
private final Set<Enrollment> enrollments = new HashSet<>();
11+
12+
public void add(Enrollment enrollment) {
13+
if (enrollments.contains(enrollment)) {
14+
throw new IllegalArgumentException(ERROR_ALREADY_ENROLLED);
15+
}
16+
enrollments.add(enrollment);
17+
}
18+
19+
public int size() {
20+
return enrollments.size();
21+
}
22+
}
23+

0 commit comments

Comments
 (0)