Skip to content
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

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
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
Expand Up @@ -8,4 +8,6 @@

public interface StudyHistoryV2Repository extends JpaRepository<StudyHistoryV2, Long>, StudyHistoryV2CustomRepository {
Optional<StudyHistoryV2> findByStudentAndStudy(Member student, StudyV2 study);

boolean existsByStudentAndStudy(Member currentMember, StudyV2 study);
}
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()) {
Copy link
Member

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를 바깥에서 받아야 합니다.
과제 제출하기 로직 참고해주세요.

Copy link
Member

Choose a reason for hiding this comment

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

-> 이 부분 #913 하면서 제가 처리하겠습니다.

throw new CustomException(ATTENDANCE_PERIOD_INVALID);
Copy link
Member

Choose a reason for hiding this comment

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

이제 봤는데 에러코드 네이밍이 조금 이상하네요.
출석기한 이내에 있지 않는데, 이 에러코드만 보면 출석기한 자체가 invalid한 것처럼 네이밍이 되어있어요. 여기서 처리할 내용은 아닌 것 같고 투두 남기고 이슈 별도로 파면 좋을 것 같습니다.

}

// 출석체크 번호 검증
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

Choose a reason for hiding this comment

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

medium

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 + " 형식이어야 합니다.";
}

Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ public enum ErrorCode {

// Attendance
ATTENDANCE_DATE_INVALID(HttpStatus.CONFLICT, "강의일이 아니면 출석체크할 수 없습니다."),
ATTENDANCE_PERIOD_INVALID(HttpStatus.CONFLICT, "강의시간이 아니면 출석체크할 수 없습니다."),
ATTENDANCE_NUMBER_MISMATCH(HttpStatus.CONFLICT, "출석번호가 일치하지 않습니다."),
STUDY_DETAIL_ALREADY_ATTENDED(HttpStatus.CONFLICT, "이미 출석 처리된 스터디입니다."),
STUDY_SESSION_ALREADY_ATTENDED(HttpStatus.CONFLICT, "이미 출석 처리된 스터디 회차입니다."),

// Order
ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "주문이 존재하지 않습니다."),
Expand Down