-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Recruitment 생성 API 구현 #345
Changes from 11 commits
444386b
157743b
823db4c
77db96c
e688ec2
11604e9
3ab5f5a
b617659
5c0ac34
de7d072
d9182d4
9e969ae
afe6a55
5bad8d5
2c9d15e
c6f6d3a
88d0290
4d6daa6
1b6e87d
4aa1ea5
862cd80
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,9 @@ | ||
package com.gdschongik.gdsc.domain.common.model; | ||
|
||
import com.gdschongik.gdsc.global.exception.CustomException; | ||
import com.gdschongik.gdsc.global.exception.ErrorCode; | ||
import java.time.LocalDateTime; | ||
import java.time.Month; | ||
import lombok.AllArgsConstructor; | ||
import lombok.Getter; | ||
|
||
|
@@ -10,4 +14,30 @@ public enum SemesterType { | |
SECOND("2학기"); | ||
|
||
private final String value; | ||
|
||
public static SemesterType from(LocalDateTime dateTime) { | ||
return getSemesterType(dateTime); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 분리하신 이유가 있나요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
분리하자는 의미 아니었나요? |
||
} | ||
|
||
private static SemesterType getSemesterType(LocalDateTime dateTime) { | ||
int year = dateTime.getYear(); | ||
LocalDateTime firstSemesterStartDate = LocalDateTime.of(year, 3, 1, 0, 0, 0); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 매직 넘버는 상수로 추출해주세요~ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3월 1일과 9월 1은 아래와 같이 처리하면 좋을 것 같네요👍👍
|
||
LocalDateTime secondSemesterStartDate = LocalDateTime.of(year, 9, 1, 0, 0, 0); | ||
|
||
if (dateTime.isAfter(firstSemesterStartDate.minusWeeks(2)) && dateTime.getMonthValue() < 7) { | ||
return FIRST; | ||
} | ||
|
||
if (dateTime.isAfter(secondSemesterStartDate.minusWeeks(2))) { | ||
return SECOND; | ||
} | ||
throw new CustomException(ErrorCode.SEMESTER_TYPE_INVALID_FOR_DATE); | ||
} | ||
|
||
public static Month getStartMonth(SemesterType semesterType) { | ||
if (semesterType == FIRST) { | ||
return Month.MARCH; | ||
} | ||
return Month.SEPTEMBER; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,29 @@ | ||||||
package com.gdschongik.gdsc.domain.recruitment.api; | ||||||
|
||||||
import com.gdschongik.gdsc.domain.recruitment.application.AdminRecruitmentService; | ||||||
import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentCreateRequest; | ||||||
import io.swagger.v3.oas.annotations.Operation; | ||||||
import io.swagger.v3.oas.annotations.tags.Tag; | ||||||
import jakarta.validation.Valid; | ||||||
import lombok.RequiredArgsConstructor; | ||||||
import org.springframework.http.ResponseEntity; | ||||||
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.RestController; | ||||||
|
||||||
@Tag(name = "Admin Recruitment", description = "어드민 리쿠르팅 관리 API입니다.") | ||||||
@RestController | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저희 근데 리크루팅으로 하자고 하지 않았나용? 기억이 안나네욥 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 너무 길다고 '리쿠르팅' 쓰기로 했던 것 같아요. ++ 영문은 recruitment로 두는게 api 경로랑 혼동이 적겠죠? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 넵넵 리크루팅은 단지 프론트/기획/백엔드 용어 통일을 위해 정한 것 같고 영어로 recruitment는 이게 리쿠르팅이다 라는것을 저희가 백끼리 이해만 하면될거같아요 |
||||||
@RequestMapping("/admin/recruitment") | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
@RequiredArgsConstructor | ||||||
public class AdminRecruitmentController { | ||||||
|
||||||
private final AdminRecruitmentService adminRecruitmentService; | ||||||
|
||||||
@Operation(summary = "리쿠르팅 생성", description = "새로운 리쿠르팅(모집 기간)를 생성합니다.") | ||||||
@PostMapping | ||||||
public ResponseEntity<Void> createRecruitment(@Valid @RequestBody RecruitmentCreateRequest request) { | ||||||
adminRecruitmentService.createRecruitment(request); | ||||||
return ResponseEntity.ok().build(); | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
package com.gdschongik.gdsc.domain.recruitment.application; | ||
|
||
import static com.gdschongik.gdsc.global.exception.ErrorCode.*; | ||
|
||
import com.gdschongik.gdsc.domain.common.model.SemesterType; | ||
import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRepository; | ||
import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; | ||
import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentCreateRequest; | ||
import com.gdschongik.gdsc.global.exception.CustomException; | ||
import java.time.LocalDateTime; | ||
import java.util.List; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
@Transactional(readOnly = true) | ||
public class AdminRecruitmentService { | ||
|
||
private final RecruitmentRepository recruitmentRepository; | ||
|
||
@Transactional | ||
public void createRecruitment(RecruitmentCreateRequest request) { | ||
validatePeriodMatchesAcademicYear(request.startDate(), request.endDate(), request.academicYear()); | ||
validatePeriodMatchesSemesterType(request.startDate(), request.endDate(), request.semesterType()); | ||
validatePeriodWithinTwoWeeks( | ||
request.startDate(), request.endDate(), request.academicYear(), request.semesterType()); | ||
validatePeriodOverlap(request.academicYear(), request.semesterType(), request.startDate(), request.endDate()); | ||
|
||
Recruitment recruitment = Recruitment.createRecruitment( | ||
request.name(), request.startDate(), request.endDate(), request.academicYear(), request.semesterType()); | ||
recruitmentRepository.save(recruitment); | ||
// todo: recruitment 모집 시작 직전에 멤버 역할 수정하는 로직 필요. | ||
} | ||
|
||
private void validatePeriodMatchesAcademicYear( | ||
LocalDateTime startDate, LocalDateTime endDate, Integer academicYear) { | ||
if (academicYear.equals(startDate.getYear()) && academicYear.equals(endDate.getYear())) { | ||
return; | ||
} | ||
|
||
throw new CustomException(RECRUITMENT_PERIOD_MISMATCH_ACADEMIC_YEAR); | ||
} | ||
|
||
private void validatePeriodMatchesSemesterType( | ||
LocalDateTime startDate, LocalDateTime endDate, SemesterType semesterType) { | ||
if (SemesterType.from(startDate).equals(semesterType) | ||
&& SemesterType.from(endDate).equals(semesterType)) { | ||
return; | ||
} | ||
|
||
throw new CustomException(RECRUITMENT_PERIOD_MISMATCH_SEMESTER_TYPE); | ||
} | ||
|
||
private void validatePeriodWithinTwoWeeks( | ||
LocalDateTime startDate, LocalDateTime endDate, Integer academicYear, SemesterType semesterType) { | ||
LocalDateTime semesterStartDate = | ||
LocalDateTime.of(academicYear, SemesterType.getStartMonth(semesterType), 1, 0, 0); | ||
|
||
if (semesterStartDate.minusWeeks(2).isAfter(startDate) | ||
|| semesterStartDate.plusWeeks(2).isBefore(startDate)) { | ||
throw new CustomException(RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS); | ||
} | ||
|
||
if (semesterStartDate.minusWeeks(2).isAfter(endDate) | ||
|| semesterStartDate.plusWeeks(2).isBefore(endDate)) { | ||
throw new CustomException(RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS); | ||
} | ||
} | ||
|
||
private void validatePeriodOverlap( | ||
Integer academicYear, SemesterType semesterType, LocalDateTime startDate, LocalDateTime endDate) { | ||
List<Recruitment> recruitments = | ||
recruitmentRepository.findAllByAcademicYearAndSemesterType(academicYear, semesterType); | ||
|
||
recruitments.forEach(recruitment -> recruitment.validatePeriodOverlap(startDate, endDate)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,11 @@ | ||
package com.gdschongik.gdsc.domain.recruitment.dao; | ||
|
||
import com.gdschongik.gdsc.domain.common.model.SemesterType; | ||
import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; | ||
import java.util.List; | ||
import org.springframework.data.jpa.repository.JpaRepository; | ||
|
||
public interface RecruitmentRepository extends JpaRepository<Recruitment, Long>, RecruitmentCustomRepository {} | ||
public interface RecruitmentRepository extends JpaRepository<Recruitment, Long>, RecruitmentCustomRepository { | ||
|
||
List<Recruitment> findAllByAcademicYearAndSemesterType(Integer academicYear, SemesterType semesterType); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package com.gdschongik.gdsc.domain.recruitment.dto.request; | ||
|
||
import static com.gdschongik.gdsc.global.common.constant.RegexConstant.*; | ||
|
||
import com.gdschongik.gdsc.domain.common.model.SemesterType; | ||
import io.swagger.v3.oas.annotations.media.Schema; | ||
import jakarta.validation.constraints.Future; | ||
import jakarta.validation.constraints.NotBlank; | ||
import jakarta.validation.constraints.NotNull; | ||
import java.time.LocalDateTime; | ||
|
||
public record RecruitmentCreateRequest( | ||
@NotBlank @Schema(description = "이름") String name, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 학년 / 학기 정보를 클라이언트로부터 받는게 낫지 않을까요? |
||
@Future @Schema(description = "모집기간 시작일", pattern = DATETIME) LocalDateTime startDate, | ||
@Future @Schema(description = "모집기간 종료일", pattern = DATETIME) LocalDateTime endDate, | ||
@NotNull(message = "학년도는 null이 될 수 없습니다.") @Schema(description = "학년도", pattern = ACADEMIC_YEAR) | ||
Integer academicYear, | ||
@NotNull(message = "학기는 null이 될 수 없습니다.") @Schema(description = "학기") SemesterType semesterType) {} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -58,6 +58,9 @@ public enum ErrorCode { | |
DISCORD_NICKNAME_NOTNULL(HttpStatus.INTERNAL_SERVER_ERROR, "닉네임은 빈 값이 될 수 없습니다."), | ||
DISCORD_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "디스코드 멤버를 찾을 수 없습니다."), | ||
|
||
// SemesterType | ||
SEMESTER_TYPE_INVALID_FOR_DATE(HttpStatus.CONFLICT, "해당 날짜가 포함되는 학기가 없습니다."), | ||
|
||
// Membership | ||
PAYMENT_NOT_VERIFIED(HttpStatus.CONFLICT, "회비 납부가 완료되지 않았습니다."), | ||
MEMBERSHIP_NOT_APPLICABLE(HttpStatus.CONFLICT, "멤버십 가입을 신청할 수 없는 회원입니다."), | ||
|
@@ -66,7 +69,11 @@ public enum ErrorCode { | |
// Recruitment | ||
DATE_PRECEDENCE_INVALID(HttpStatus.BAD_REQUEST, "종료일이 시작일과 같거나 앞설 수 없습니다."), | ||
RECRUITMENT_NOT_OPEN(HttpStatus.CONFLICT, "리크루트먼트 모집기간이 아닙니다."), | ||
RECRUITMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "열려있는 리크루트먼트가 없습니다."); | ||
RECRUITMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "열려있는 리크루트먼트가 없습니다."), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 한글 워딩은 '리쿠르팅'으로 통일해야 합니다. 해당 건은 별도 이슈로 작업하셔도 됩니다 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 전범위로 검색해보니 여기에만 있네요 |
||
RECRUITMENT_PERIOD_OVERLAP(HttpStatus.BAD_REQUEST, "모집 기간이 중복됩니다."), | ||
RECRUITMENT_PERIOD_MISMATCH_ACADEMIC_YEAR(HttpStatus.BAD_REQUEST, "모집 시작일과 종료일의 연도가 학년도와 일치하지 않습니다."), | ||
RECRUITMENT_PERIOD_MISMATCH_SEMESTER_TYPE(HttpStatus.BAD_REQUEST, "모집 시작일과 종료일의 입력된 학기가 일치하지 않습니다."), | ||
RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS(HttpStatus.BAD_REQUEST, "모집 시작일과 종료일이 학기 시작일로부터 2주 이내에 있지 않습니다."); | ||
|
||
private final HttpStatus status; | ||
private final String message; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
package com.gdschongik.gdsc.domain.recruitment.application; | ||
|
||
import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; | ||
import static com.gdschongik.gdsc.global.exception.ErrorCode.*; | ||
import static org.assertj.core.api.Assertions.*; | ||
|
||
import com.gdschongik.gdsc.domain.common.model.SemesterType; | ||
import com.gdschongik.gdsc.domain.recruitment.dao.RecruitmentRepository; | ||
import com.gdschongik.gdsc.domain.recruitment.domain.Recruitment; | ||
import com.gdschongik.gdsc.domain.recruitment.dto.request.RecruitmentCreateRequest; | ||
import com.gdschongik.gdsc.global.exception.CustomException; | ||
import com.gdschongik.gdsc.integration.IntegrationTest; | ||
import java.time.LocalDateTime; | ||
import org.junit.jupiter.api.Nested; | ||
import org.junit.jupiter.api.Test; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
|
||
class AdminRecruitmentServiceTest extends IntegrationTest { | ||
|
||
@Autowired | ||
private AdminRecruitmentService adminRecruitmentService; | ||
|
||
@Autowired | ||
private RecruitmentRepository recruitmentRepository; | ||
|
||
private void createRecruitment() { | ||
Recruitment recruitment = | ||
Recruitment.createRecruitment(RECRUITMENT_NAME, START_DATE, END_DATE, ACADEMIC_YEAR, SEMESTER_TYPE); | ||
recruitmentRepository.save(recruitment); | ||
} | ||
|
||
@Nested | ||
class 모집기간_생성시 { | ||
@Test | ||
void 기간이_중복되는_Recruitment가_있다면_실패한다() { | ||
// given | ||
createRecruitment(); | ||
RecruitmentCreateRequest request = | ||
new RecruitmentCreateRequest(RECRUITMENT_NAME, START_DATE, END_DATE, ACADEMIC_YEAR, SEMESTER_TYPE); | ||
|
||
// when & then | ||
assertThatThrownBy(() -> adminRecruitmentService.createRecruitment(request)) | ||
.isInstanceOf(CustomException.class) | ||
.hasMessage(RECRUITMENT_PERIOD_OVERLAP.getMessage()); | ||
} | ||
|
||
@Test | ||
void 모집_시작일과_종료일의_연도가_입력된_학년도와_다르다면_실패한다() { | ||
// given | ||
RecruitmentCreateRequest request = | ||
new RecruitmentCreateRequest(RECRUITMENT_NAME, START_DATE, END_DATE, 2025, SEMESTER_TYPE); | ||
|
||
// when & then | ||
assertThatThrownBy(() -> adminRecruitmentService.createRecruitment(request)) | ||
.isInstanceOf(CustomException.class) | ||
.hasMessage(RECRUITMENT_PERIOD_MISMATCH_ACADEMIC_YEAR.getMessage()); | ||
} | ||
|
||
@Test | ||
void 모집_시작일과_종료일의_학기가_입력된_학기와_다르다면_실패한다() { | ||
// given | ||
RecruitmentCreateRequest request = new RecruitmentCreateRequest( | ||
RECRUITMENT_NAME, START_DATE, END_DATE, ACADEMIC_YEAR, SemesterType.SECOND); | ||
|
||
// when & then | ||
assertThatThrownBy(() -> adminRecruitmentService.createRecruitment(request)) | ||
.isInstanceOf(CustomException.class) | ||
.hasMessage(RECRUITMENT_PERIOD_MISMATCH_SEMESTER_TYPE.getMessage()); | ||
} | ||
|
||
@Test | ||
void 모집_시작일과_종료일이_학기_시작일로부터_2주_이내에_있지_않다면_실패한다() { | ||
// given | ||
RecruitmentCreateRequest request = new RecruitmentCreateRequest( | ||
RECRUITMENT_NAME, START_DATE, LocalDateTime.of(2024, 4, 10, 00, 00), ACADEMIC_YEAR, SEMESTER_TYPE); | ||
|
||
// when & then | ||
assertThatThrownBy(() -> adminRecruitmentService.createRecruitment(request)) | ||
.isInstanceOf(CustomException.class) | ||
.hasMessage(RECRUITMENT_PERIOD_NOT_WITHIN_TWO_WEEKS.getMessage()); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 메서드가 적절한 책임을 가지지 못하는 것 같습니다.
저라면 이 메서드를 보고 "대충 enum 멤버로 학기 시작일이 있으니까, 인자로 받은 dateTime이 학기 시작일 ~ 종료일 사이이면 해당 학기타입을 반환하겠구나?" 라고 생각할 것 같아요.
그런데 실제 로직을 보면 학기 시작일 2주 전에 해당하는 dateTime도 해당 학기타입으로 매핑해주고 있습니다. 이렇게 하면 처음 보는 사람은 조금 혼란스러울 것 같다는 생각이 듭니다.
저희가 이렇게 했던 이유가 뭐였나요? 인자로 들어오는 값이 리쿠르팅의 시작일과 마감일이었기 때문이었죠.
여기서 두 가지 문제가 있는데요,
SemesterType::from
으로 들어오는 값이 항상 리쿠르팅의 시작일과 마감일이라는 보장이 없다는 겁니다. 다른 로직에서 언제든지 실수하고 아, LocalDateTime을 SemesterType으로 매핑해주는 정적 메서드구나 하고 쓸 여지가 많습니다.2번의 경우 인자를 Recruitment로 받거나 하면 해결될 수 있지만 1번을 해결 못했기 때문에 근본적인 해결책은 아니죠.
결국 학기타입 매핑 로직은 리쿠르팅 서비스로 빼내는 것이 적절해보입니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
그렇네요.
저도 SemesterType.from이 항상 LocalDateTime만 받는게 맞을지, 그리고 받는 LocalDateTime에 대해 항상 같은 로직으로 SemesterType을 반환하는게 맞을지에 대한 고민이 있었는데 서비스로 옮기니 마음에 걸리던데 해소된 느낌이네요👍