Skip to content

Commit

Permalink
🚀 [feature] 학생 출석 확인 API 구현 (#121)
Browse files Browse the repository at this point in the history
* refactor : AttendanceService의 public method들을 최상단으로 이동 (#117)

* refactor : Attendance의 Member 이름을 student로 변경 (#117)

* refactor : AttendanceResponse를 용도에 맞게 AttendancesResponse로 이름 변경 (#117)

* feat : 학생의 출석 기록 확인 기능 구현 (#117)

* refactor : 강사의 출석 정보 조회 응답에 강의 정보 제거 (#117)

* refactor : 출석시 로깅 메서드 분리 (#117)

* refactor : 유저 권한 확인 메서드가 검증할 권한을 입력 받도록 변경 (#117)

* refactor : 강사용 출석 기록 확인 API에 강사 권한 검증 추가 (#117)

* refactor : 학생의 최근 출석 정보를 가져오는 기능 구현 (#117)

* chore : OpenLecture의 정적 팩터리 메서드 이름 변경 (#117)

* refactor : key를 스스로 만드는 것을 강제하는 추상 클래스 CacheEntity 구현 (#117)

* feat : 출석 기록을 저장하는 AttendanceHistory 구현 (#117)

* feat :AttendanceHistory를 캐싱하는 AttendanceHistoryCacheRepository 구현 (#117)

* feat :StudentAttendedEvent와 AttendanceHistory를 캐싱하는 Listener 구현 (#117)

* refactor : RedisKeyPrefixes의 이름을 RedisKeyConstants로 변경 (#117)

* feat : 학생 출석시 StudentAttendedEvent를 발행하는 기능 구현 (#117)

* feat : Look Aside로 최근 출석 내역을 가져오는 getStudentRecentAttendance 구현 (#117)

* refactor : AttendanceService를 학생, 강사를 기준으로 분리 (#117)

* feat : 테스트를 위한 FakeAttendanceHistoryCacheRepository 구현 (#117)

* refactor : 강사용 출석 정보 API에서 요청자가 강사인지 검증하는 메서드 추가 (#117)

* test : 코드 변경에 맞춰 테스트 코드 변경 (#117)

* chore : Attendance 객체 패키지 변경 (#117)

* refactor : AttendanceController Endpoint Restful하게 변경 (#117)

* refactor : 학생 출석 조회 API 구현 (#117)

* test : 학생 출석 조회 API Controller Slice Test 구현 (#117)

* chore : Response 객체 패키지 변경 (#117)

* refactor : AttendanceHistory 객체 캐싱 시간 설정 (#117)
  • Loading branch information
binary-ho authored Mar 14, 2024
1 parent 2a39544 commit cef49b6
Show file tree
Hide file tree
Showing 29 changed files with 678 additions and 235 deletions.
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package gdsc.binaryho.imhere.config.redis;

public class RedisKeyPrefixes {
public class RedisKeyConstants {

public static final String ATTENDANCE_NUMBER_KEY_PREFIX = "$lecture_id$";
public static final String VERIFICATION_CODE_KEY_PREFIX = "$email$";
public static final String OPEN_LECTURE_KEY_PREFIX = "$open_lecture$";
public static final String LECTURE_STUDENT_KEY_PREFIX = "$lecture_student$";
public static final String ATTENDANCE_HISTORY_KEY_FORMAT = "$attendance_history$lecture_id:%d$student_id:%d$";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package gdsc.binaryho.imhere.core.attendance.application;

import gdsc.binaryho.imhere.core.attendance.application.port.AttendanceHistoryCacheRepository;
import gdsc.binaryho.imhere.core.attendance.domain.AttendanceHistory;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Service
@RequiredArgsConstructor
public class AttendanceHistoryCacheService {

private final AttendanceHistoryCacheRepository attendanceHistoryCacheRepository;

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void cache(StudentAttendedEvent event) {
long lectureId = event.getLectureId();
long studentId = event.getStudentId();
String timestamp = event.getTimestamp().toString();

AttendanceHistory attendanceHistory = AttendanceHistory.of(
lectureId, studentId, timestamp);
attendanceHistoryCacheRepository.cache(attendanceHistory);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package gdsc.binaryho.imhere.core.attendance.application;


import gdsc.binaryho.imhere.core.attendance.domain.Attendance;
import gdsc.binaryho.imhere.core.attendance.infrastructure.AttendanceRepository;
import gdsc.binaryho.imhere.core.attendance.model.response.LecturerAttendanceResponse;
import gdsc.binaryho.imhere.core.lecture.domain.Lecture;
import gdsc.binaryho.imhere.core.lecture.exception.LectureNotFoundException;
import gdsc.binaryho.imhere.core.lecture.infrastructure.LectureRepository;
import gdsc.binaryho.imhere.core.member.Role;
import gdsc.binaryho.imhere.security.util.AuthenticationHelper;
import gdsc.binaryho.imhere.util.SeoulDateTimeHolder;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Log4j2
@Service
@RequiredArgsConstructor
public class LecturerAttendanceService {

private final AttendanceRepository attendanceRepository;
private final LectureRepository lectureRepository;

private final SeoulDateTimeHolder seoulDateTimeHolder;
private final AuthenticationHelper authenticationHelper;

@Transactional(readOnly = true)
public LecturerAttendanceResponse getLecturerAttendances(Long lectureId) {
authenticationHelper.verifyMemberHasRole(Role.LECTURER);

List<Attendance> attendances = attendanceRepository.findAllByLectureId(lectureId);
validateLectureOwnerRequested(lectureId, attendances);
return new LecturerAttendanceResponse(attendances);
}

@Transactional(readOnly = true)
public LecturerAttendanceResponse getLecturerDayAttendances(Long lectureId, Long milliseconds) {
authenticationHelper.verifyMemberHasRole(Role.LECTURER);

LocalDateTime timestamp = getTodaySeoulDateTime(milliseconds);
List<Attendance> attendances = attendanceRepository
.findByLectureIdAndTimestampBetween(lectureId, timestamp, timestamp.plusDays(1));
validateLectureOwnerRequested(lectureId, attendances);
return new LecturerAttendanceResponse(attendances);
}

private LocalDateTime getTodaySeoulDateTime(Long milliseconds) {
return seoulDateTimeHolder.from(milliseconds)
.withHour(0).withMinute(0).withSecond(0);
}

private void validateLectureOwnerRequested(Long lectureId, List<Attendance> attendances) {
attendances.stream()
.findAny()
.ifPresentOrElse(
attendance -> authenticationHelper.verifyRequestMemberLogInMember(getLecturerId(attendance)),
() -> authenticationHelper.verifyRequestMemberLogInMember(getLecturerId(lectureId))
);
}

private long getLecturerId(Attendance attendance) {
Lecture lecture = attendance.getLecture();
return lecture.getMember().getId();
}

private long getLecturerId(Long id) {
Lecture lecture = lectureRepository.findById(id)
.orElseThrow(() -> LectureNotFoundException.EXCEPTION);
return lecture.getMember().getId();
}
}
Original file line number Diff line number Diff line change
@@ -1,50 +1,59 @@
package gdsc.binaryho.imhere.core.attendance.application;


import gdsc.binaryho.imhere.core.attendance.Attendance;
import gdsc.binaryho.imhere.core.attendance.domain.Attendance;
import gdsc.binaryho.imhere.core.attendance.application.port.AttendanceHistoryCacheRepository;
import gdsc.binaryho.imhere.core.attendance.domain.AttendanceHistory;
import gdsc.binaryho.imhere.core.attendance.exception.AttendanceNumberIncorrectException;
import gdsc.binaryho.imhere.core.attendance.exception.AttendanceTimeExceededException;
import gdsc.binaryho.imhere.core.attendance.infrastructure.AttendanceRepository;
import gdsc.binaryho.imhere.core.attendance.model.request.AttendanceRequest;
import gdsc.binaryho.imhere.core.attendance.model.response.AttendanceResponse;
import gdsc.binaryho.imhere.core.attendance.model.response.StudentAttendanceResponse;
import gdsc.binaryho.imhere.core.attendance.model.response.StudentRecentAttendanceResponse;
import gdsc.binaryho.imhere.core.enrollment.EnrollmentInfo;
import gdsc.binaryho.imhere.core.enrollment.EnrollmentState;
import gdsc.binaryho.imhere.core.enrollment.exception.EnrollmentNotApprovedException;
import gdsc.binaryho.imhere.core.enrollment.infrastructure.EnrollmentInfoRepository;
import gdsc.binaryho.imhere.core.lecture.LectureState;
import gdsc.binaryho.imhere.core.lecture.application.OpenLectureService;
import gdsc.binaryho.imhere.core.lecture.domain.Lecture;
import gdsc.binaryho.imhere.core.lecture.exception.LectureNotFoundException;
import gdsc.binaryho.imhere.core.lecture.exception.LectureNotOpenException;
import gdsc.binaryho.imhere.core.lecture.infrastructure.LectureRepository;
import gdsc.binaryho.imhere.core.member.Member;
import gdsc.binaryho.imhere.security.util.AuthenticationHelper;
import gdsc.binaryho.imhere.util.SeoulDateTimeHolder;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Log4j2
@Service
@RequiredArgsConstructor
public class AttendanceService {
public class StudentAttendanceService {

private final AuthenticationHelper authenticationHelper;
private final OpenLectureService openLectureService;
private final AttendanceRepository attendanceRepository;
private final EnrollmentInfoRepository enrollmentRepository;
private final LectureRepository lectureRepository;
private final AttendanceHistoryCacheRepository attendanceHistoryCacheRepository;
private final ApplicationEventPublisher eventPublisher;

private final SeoulDateTimeHolder seoulDateTimeHolder;
private final AuthenticationHelper authenticationHelper;

private final static Duration RECENT_TIME = Duration.ofHours(1L);

@Transactional
public void takeAttendance(AttendanceRequest attendanceRequest, Long lectureId) {
Member currentStudent = authenticationHelper.getCurrentMember();
EnrollmentInfo enrollmentInfo = enrollmentRepository
.findByMemberIdAndLectureIdAndEnrollmentState(currentStudent.getId(), lectureId, EnrollmentState.APPROVAL)
.findByMemberIdAndLectureIdAndEnrollmentState(currentStudent.getId(), lectureId,
EnrollmentState.APPROVAL)
.orElseThrow(() -> EnrollmentNotApprovedException.EXCEPTION);

validateLectureOpen(enrollmentInfo);
Expand All @@ -53,25 +62,60 @@ public void takeAttendance(AttendanceRequest attendanceRequest, Long lectureId)
attend(attendanceRequest, enrollmentInfo);
}

private void attend(AttendanceRequest attendanceRequest, EnrollmentInfo enrollmentInfo) {
@Transactional(readOnly = true)
public StudentRecentAttendanceResponse getStudentRecentAttendance(Long lectureId) {
Long studentId = authenticationHelper.getCurrentMember().getId();

Attendance attendance = Attendance.createAttendance(
enrollmentInfo.getMember(),
enrollmentInfo.getLecture(),
attendanceRequest.getDistance(),
attendanceRequest.getAccuracy(),
seoulDateTimeHolder.from(attendanceRequest.getMilliseconds())
);
List<AttendanceHistory> attendanceHistories = attendanceHistoryCacheRepository
.findAllByLectureIdAndStudentId(lectureId, studentId);

attendanceRepository.save(attendance);
if (attendanceHistories.isEmpty()) {
List<String> timestamps = getRecentAttendanceTimestamps(lectureId, studentId);
return new StudentRecentAttendanceResponse(timestamps);
}

Lecture lecture = attendance.getLecture();
Member attendMember = enrollmentInfo.getMember();
log.info("[출석 완료] {}({}) , 학생 : {} ({})",
lecture::getLectureName, lecture::getId,
attendMember::getUnivId, attendMember::getName);
List<String> timestamps = getTimestamps(attendanceHistories);
return new StudentRecentAttendanceResponse(timestamps);
}

@Transactional(readOnly = true)
public StudentAttendanceResponse getStudentDayAttendance(Long lectureId, Long milliseconds) {
LocalDateTime timestamp = getTodaySeoulDateTime(milliseconds);
Long studentId = authenticationHelper.getCurrentMember().getId();
List<Attendance> attendances = attendanceRepository
.findByLectureIdAndStudentIdAndTimestampBetween(
lectureId, studentId, timestamp, timestamp.plusDays(1));

return new StudentAttendanceResponse(attendances);
}

private List<String> getRecentAttendanceTimestamps(Long lectureId, Long studentId) {
List<Attendance> attendances = findRecentAttendances(lectureId, studentId);
List<String> timestamps = attendances.stream()
.map(Attendance::getTimestamp)
.map(LocalDateTime::toString)
.collect(Collectors.toList());
return timestamps;
}

private List<Attendance> findRecentAttendances(Long lectureId, Long studentId) {
LocalDateTime now = seoulDateTimeHolder.getSeoulDateTime();
LocalDateTime beforeRecentTime = now.minusHours(RECENT_TIME.toHours());

List<Attendance> attendances = attendanceRepository
.findByLectureIdAndStudentIdAndTimestampBetween(
lectureId, studentId, beforeRecentTime, now);
return attendances;
}

private List<String> getTimestamps(List<AttendanceHistory> attendanceHistories) {
return attendanceHistories.stream()
.map(AttendanceHistory::getTimestamp)
.map(Objects::toString)
.collect(Collectors.toList());
}


private void validateLectureOpen(EnrollmentInfo enrollmentInfo) {
if (enrollmentInfo.getLecture().getLectureState() != LectureState.OPEN) {
throw LectureNotOpenException.EXCEPTION;
Expand All @@ -86,55 +130,47 @@ private void validateAttendanceNumber(EnrollmentInfo enrollmentInfo, int attenda
validateAttendanceNumberCorrect(actualAttendanceNumber, attendanceNumber);
}

private void validateAttendanceNumberNotTimeOut(Integer attendanceNumber) {
if (attendanceNumber == null) {
throw AttendanceTimeExceededException.EXCEPTION;
}
}
private void attend(AttendanceRequest attendanceRequest, EnrollmentInfo enrollmentInfo) {
Member student = enrollmentInfo.getMember();
Lecture lecture = enrollmentInfo.getLecture();
Attendance attendance = Attendance.createAttendance(
student, lecture,
attendanceRequest.getDistance(),
attendanceRequest.getAccuracy(),
seoulDateTimeHolder.from(attendanceRequest.getMilliseconds())
);

private void validateAttendanceNumberCorrect(Integer actualAttendanceNumber, int attendanceNumber) {
if (actualAttendanceNumber != attendanceNumber) {
throw AttendanceNumberIncorrectException.EXCEPTION;
}
attendanceRepository.save(attendance);
publishStudentAttendedEvent(attendance, lecture, student);
logAttendanceHistory(enrollmentInfo, attendance);
}

@Transactional(readOnly = true)
public AttendanceResponse getAttendances(Long lectureId) {
List<Attendance> attendances = attendanceRepository.findAllByLectureId(lectureId);

if (attendances.isEmpty()) {
return getNullAttendanceDto(lectureId);
}

Lecture lecture = attendances.get(0).getLecture();
verifyRequestMemberLogInMember(lecture.getMember());

return new AttendanceResponse(lecture, attendances);
private void publishStudentAttendedEvent(
Attendance attendance, Lecture lecture, Member student) {
LocalDateTime timestamp = attendance.getTimestamp();
eventPublisher.publishEvent(
new StudentAttendedEvent(lecture.getId(), student.getId(), timestamp));
}

private void verifyRequestMemberLogInMember(Member lecturer) {
authenticationHelper.verifyRequestMemberLogInMember(lecturer.getId());
private void logAttendanceHistory(EnrollmentInfo enrollmentInfo, Attendance attendance) {
Lecture lecture = attendance.getLecture();
Member attendMember = enrollmentInfo.getMember();
log.info("[출석 완료] {}({}) , 학생 : {} ({})",
lecture::getLectureName, lecture::getId,
attendMember::getUnivId, attendMember::getName);
}

private AttendanceResponse getNullAttendanceDto(Long lectureId) {
Lecture lecture = lectureRepository.findById(lectureId)
.orElseThrow(() -> LectureNotFoundException.EXCEPTION);
return new AttendanceResponse(lecture, Collections.emptyList());
private void validateAttendanceNumberNotTimeOut(Integer attendanceNumber) {
if (attendanceNumber == null) {
throw AttendanceTimeExceededException.EXCEPTION;
}
}

@Transactional(readOnly = true)
public AttendanceResponse getDayAttendances(Long lectureId, Long milliseconds) {
LocalDateTime timestamp = getTodaySeoulDateTime(milliseconds);
List<Attendance> attendances = attendanceRepository
.findByLectureIdAndTimestampBetween(lectureId, timestamp, timestamp.plusDays(1));

if (attendances.isEmpty()) {
return getNullAttendanceDto(lectureId);
private void validateAttendanceNumberCorrect(Integer actualAttendanceNumber,
int attendanceNumber) {
if (actualAttendanceNumber != attendanceNumber) {
throw AttendanceNumberIncorrectException.EXCEPTION;
}

Lecture lecture = attendances.get(0).getLecture();
verifyRequestMemberLogInMember(lecture.getMember());
return new AttendanceResponse(lecture, attendances);
}

private LocalDateTime getTodaySeoulDateTime(Long milliseconds) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package gdsc.binaryho.imhere.core.attendance.application;

import java.time.LocalDateTime;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class StudentAttendedEvent {

private final long lectureId;
private final long studentId;
private final LocalDateTime timestamp;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package gdsc.binaryho.imhere.core.attendance.application.port;

import gdsc.binaryho.imhere.core.attendance.domain.AttendanceHistory;
import java.util.List;

public interface AttendanceHistoryCacheRepository {

List<AttendanceHistory> findAllByLectureIdAndStudentId(long lectureId, long studentId);

void cache(AttendanceHistory attendanceHistory);
}
Loading

0 comments on commit cef49b6

Please sign in to comment.