-
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: 스터디 출석체크 V2 API 작성 #942
base: develop
Are you sure you want to change the base?
Changes from all commits
2d85c7d
23e6554
3f6e748
5eaf7db
733bfd0
a957b84
19f2c89
e4b2b89
e2cd68d
2f31956
7825eda
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 |
---|---|---|
@@ -0,0 +1,32 @@ | ||
package com.gdschongik.gdsc.domain.studyv2.api; | ||
|
||
import com.gdschongik.gdsc.domain.studyv2.application.StudentStudyServiceV2; | ||
import com.gdschongik.gdsc.domain.studyv2.dto.request.AttendanceCreateRequest; | ||
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.RequestParam; | ||
import org.springframework.web.bind.annotation.RestController; | ||
|
||
@Tag(name = "Student Study Attendance V2", description = "사용자 스터디 출석체크 V2 API입니다.") | ||
@RestController | ||
@RequestMapping("/v2/attendances") | ||
@RequiredArgsConstructor | ||
public class StudentAttendanceControllerV2 { | ||
|
||
private final StudentStudyServiceV2 studentStudyServiceV2; | ||
|
||
@Operation(summary = "스터디 출석체크", description = "스터디에 출석체크합니다.") | ||
@PostMapping("/attend") | ||
public ResponseEntity<Void> attend( | ||
@RequestParam(name = "studySessionId") Long studySessionId, | ||
@Valid @RequestBody AttendanceCreateRequest request) { | ||
studentStudyServiceV2.attend(studySessionId, request); | ||
return ResponseEntity.ok().build(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
package com.gdschongik.gdsc.domain.studyv2.application; | ||
|
||
import static com.gdschongik.gdsc.global.exception.ErrorCode.STUDY_NOT_FOUND; | ||
|
||
import com.gdschongik.gdsc.domain.member.domain.Member; | ||
import com.gdschongik.gdsc.domain.studyv2.dao.AttendanceV2Repository; | ||
import com.gdschongik.gdsc.domain.studyv2.dao.StudyHistoryV2Repository; | ||
import com.gdschongik.gdsc.domain.studyv2.dao.StudyV2Repository; | ||
import com.gdschongik.gdsc.domain.studyv2.domain.AttendanceV2; | ||
import com.gdschongik.gdsc.domain.studyv2.domain.AttendanceValidatorV2; | ||
import com.gdschongik.gdsc.domain.studyv2.domain.StudySessionV2; | ||
import com.gdschongik.gdsc.domain.studyv2.domain.StudyV2; | ||
import com.gdschongik.gdsc.domain.studyv2.dto.request.AttendanceCreateRequest; | ||
import com.gdschongik.gdsc.global.exception.CustomException; | ||
import com.gdschongik.gdsc.global.util.MemberUtil; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Slf4j | ||
@Service | ||
@RequiredArgsConstructor | ||
public class StudentStudyServiceV2 { | ||
|
||
private final MemberUtil memberUtil; | ||
private final StudyV2Repository studyV2Repository; | ||
private final AttendanceV2Repository attendanceV2Repository; | ||
private final StudyHistoryV2Repository studyHistoryV2Repository; | ||
private final AttendanceValidatorV2 attendanceValidatorV2; | ||
|
||
@Transactional | ||
public void attend(Long studySessionId, AttendanceCreateRequest request) { | ||
Member currentMember = memberUtil.getCurrentMember(); | ||
StudyV2 study = studyV2Repository | ||
.findFetchBySessionId(studySessionId) | ||
.orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); | ||
StudySessionV2 studySession = study.getStudySession(studySessionId); | ||
|
||
boolean isAppliedToStudy = studyHistoryV2Repository.existsByStudentAndStudy(currentMember, study); | ||
boolean isAlreadyAttended = attendanceV2Repository.existsByStudentAndStudySession(currentMember, studySession); | ||
|
||
attendanceValidatorV2.validateAttendance( | ||
studySession, request.attendanceNumber(), isAppliedToStudy, isAlreadyAttended); | ||
|
||
AttendanceV2 attendance = AttendanceV2.create(currentMember, studySession); | ||
attendanceV2Repository.save(attendance); | ||
|
||
log.info( | ||
"[StudentStudyServiceV2] 스터디 출석체크: attendanceId={}, memberId={}", | ||
attendance.getId(), | ||
currentMember.getId()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package com.gdschongik.gdsc.domain.studyv2.dao; | ||
|
||
import com.gdschongik.gdsc.domain.member.domain.Member; | ||
import com.gdschongik.gdsc.domain.studyv2.domain.AttendanceV2; | ||
import com.gdschongik.gdsc.domain.studyv2.domain.StudySessionV2; | ||
import org.springframework.data.jpa.repository.JpaRepository; | ||
|
||
public interface AttendanceV2Repository extends JpaRepository<AttendanceV2, Long> { | ||
|
||
boolean existsByStudentAndStudySession(Member student, StudySessionV2 studySession); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
package com.gdschongik.gdsc.domain.studyv2.domain; | ||
|
||
import static com.gdschongik.gdsc.global.exception.ErrorCode.ATTENDANCE_NUMBER_MISMATCH; | ||
import static com.gdschongik.gdsc.global.exception.ErrorCode.ATTENDANCE_PERIOD_INVALID; | ||
import static com.gdschongik.gdsc.global.exception.ErrorCode.STUDY_HISTORY_NOT_FOUND; | ||
import static com.gdschongik.gdsc.global.exception.ErrorCode.STUDY_SESSION_ALREADY_ATTENDED; | ||
|
||
import com.gdschongik.gdsc.global.annotation.DomainService; | ||
import com.gdschongik.gdsc.global.exception.CustomException; | ||
|
||
@DomainService | ||
public class AttendanceValidatorV2 { | ||
|
||
public void validateAttendance( | ||
StudySessionV2 studySession, String attendanceNumber, boolean isAppliedToStudy, boolean isAlreadyAttended) { | ||
// 스터디 신청 여부 검증 | ||
if (!isAppliedToStudy) { | ||
throw new CustomException(STUDY_HISTORY_NOT_FOUND); | ||
} | ||
|
||
// 스터디 중복 출석체크 여부 검증 | ||
if (isAlreadyAttended) { | ||
throw new CustomException(STUDY_SESSION_ALREADY_ATTENDED); | ||
} | ||
|
||
// 출석체크 가능 기간 검증 | ||
if (!studySession.getLessonPeriod().isOpen()) { | ||
throw new CustomException(ATTENDANCE_PERIOD_INVALID); | ||
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. 이제 봤는데 에러코드 네이밍이 조금 이상하네요. |
||
} | ||
|
||
// 출석체크 번호 검증 | ||
if (!studySession.getLessonAttendanceNumber().equals(attendanceNumber)) { | ||
throw new CustomException(ATTENDANCE_NUMBER_MISMATCH); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
package com.gdschongik.gdsc.domain.studyv2.dto.request; | ||
|
||
import static com.gdschongik.gdsc.global.common.constant.RegexConstant.ATTENDANCE_NUMBER; | ||
|
||
import io.swagger.v3.oas.annotations.media.Schema; | ||
import jakarta.validation.constraints.NotBlank; | ||
import jakarta.validation.constraints.Pattern; | ||
|
||
public record AttendanceCreateRequest( | ||
@NotBlank | ||
@Pattern(regexp = ATTENDANCE_NUMBER, message = "출석번호는 " + ATTENDANCE_NUMBER + " 형식이어야 합니다.") | ||
@Schema(description = "출석번호") | ||
String attendanceNumber) {} | ||
Comment on lines
+11
to
+13
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. Consider extracting the message "출석번호는 " + ATTENDANCE_NUMBER + " 형식이어야 합니다." to a constant to avoid duplication and improve maintainability. This also makes it easier to update the message in one place if needed. import static com.gdschongik.gdsc.global.common.constant.RegexConstant.ATTENDANCE_NUMBER;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record AttendanceCreateRequest(
@NotBlank
@Pattern(regexp = ATTENDANCE_NUMBER, message = AttendanceCreateRequest.ATTENDANCE_NUMBER_MESSAGE)
@Schema(description = "출석번호")
String attendanceNumber) {
private static final String ATTENDANCE_NUMBER_MESSAGE = "출석번호는 " + ATTENDANCE_NUMBER + " 형식이어야 합니다.";
} |
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.
isOpen
은 deprecation 처리하는 게 좋아보입니다.대신
isWithin
같이 유즈케이스로부터 독립적인 메서드를 추가하고, now를 인자로 받아서 처리하면 좋을 것 같습니다. '주어진 값이 시작일시와 종료일시 사이에 있다' 라는 걸 '기간이 열려있다' 라고 하지는 않잖아요?'열려있다' 라는 건 Recruitment 도메인 구현하면서 잘못 추출된 워딩입니다.
해당 모집회차가 '열려있다' 라는 표현이 여기로 넘어온 것이죠.
'기한'은 '열려있다' 라는 표현보다는 '이내에 있다' 라는 표현이 적절하고, 따라서
isWithin
이 적절한 네이밍으로 보입니다. 사실 전에 assignmentPeriod 구현하면서 별도로 이슈 파서 추가할까 고민했는데 선반영하면 좋을 것 같아서 남깁니다.그리고 기존
isOpen
로직은 오른쪽 끝에 대해서 inclusive하게 동작하는데, exclusive하게 구현을 변경하는 게 더 올바를 것 같아요. 1일 00시 제출 건을 받게 되어버리면 일자가 바뀌기 때문에...한편 도메인 서비스도 마찬가지로 now를 바깥에서 받아야 합니다.
과제 제출하기 로직 참고해주세요.
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.
-> 이 부분 #913 하면서 제가 처리하겠습니다.