Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions common/api/src/main/java/kr/spot/code/status/ErrorStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ public enum ErrorStatus implements BaseErrorCode {
// 일정 관련
_SCHEDULE_NOT_FOUND(404, "SCHEDULE404", "일정을 찾을 수 없습니다."),
_SCHEDULE_ACCESS_DENIED(403, "SCHEDULE403", "해당 스터디에 속하는 일정이 아닙니다."),
_SCHEDULE_QR_CODE_ALREADY_ASSIGNED(400, "SCHEDULE4000", "이미 출석 QR 코드가 할당된 일정입니다."),

// 출석 관련
_ATTENDANCE_NOT_IN_SCHEDULE_TIME(400, "ATTENDANCE4000", "일정 시간 외에는 출석체크를 진행할 수 없습니다."),
_ATTENDANCE_NOT_STARTED(400, "ATTENDANCE4001", "출석체크가 시작되지 않았습니다."),
_ATTENDANCE_ALREADY_CHECKED(400, "ATTENDANCE4002", "이미 출석체크를 완료했습니다."),
_INVALID_ATTENDANCE_TOKEN(400, "ATTENDANCE4003", "유효하지 않은 출석 토큰입니다."),

// 투두 관련
_TODO_NOT_FOUND(404, "TODO404", "투두를 찾을 수 없습니다."),
Expand Down
4 changes: 4 additions & 0 deletions modules/study/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ dependencies {
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

// QR Code
implementation "com.google.zxing:core:3.5.2"
implementation "com.google.zxing:javase:3.5.2"
}

def querydslDir = "$buildDir/generated/querydsl"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package kr.spot.schedule.application.command;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import kr.spot.IdGenerator;
import kr.spot.code.status.ErrorStatus;
import kr.spot.exception.GeneralException;
import kr.spot.ports.GetMemberInfoPort;
import kr.spot.ports.dto.MemberInfoResponse;
import kr.spot.schedule.application.event.AttendanceStartedEvent;
import kr.spot.schedule.domain.Attendance;
import kr.spot.schedule.domain.Schedule;
import kr.spot.schedule.domain.enums.AttendanceStatus;
import kr.spot.schedule.domain.vo.MemberInfo;
import kr.spot.schedule.infrastructure.crypto.AttendanceTokenEncryptor;
import kr.spot.schedule.infrastructure.jpa.AttendanceRepository;
import kr.spot.schedule.infrastructure.jpa.ScheduleRepository;
import kr.spot.study.application.validator.StudyAccessValidator;
import kr.spot.study.domain.associations.StudyMember;
import kr.spot.study.domain.enums.StudyMemberStatus;
import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
@RequiredArgsConstructor
public class AttendanceCommandService {

private static final List<StudyMemberStatus> ACTIVE_MEMBER_STATUSES =
List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED);

private final IdGenerator idGenerator;
private final ScheduleRepository scheduleRepository;
private final AttendanceRepository attendanceRepository;
private final StudyMemberRepository studyMemberRepository;
private final StudyAccessValidator studyAccessValidator;
private final GetMemberInfoPort getMemberInfoPort;
private final ApplicationEventPublisher eventPublisher;
private final AttendanceTokenEncryptor tokenEncryptor;

public void startAttendance(long studyId, long scheduleId, long memberId) {
studyAccessValidator.validateStudyLeader(studyId, memberId);

Schedule schedule = scheduleRepository.getById(scheduleId);
schedule.startAttendance(studyId);

createAttendancesForAllMembers(studyId, scheduleId);

String qrContent = generateEncryptedToken(studyId, scheduleId);
eventPublisher.publishEvent(AttendanceStartedEvent.of(studyId, scheduleId, qrContent));
}

public void stopAttendance(long studyId, long scheduleId, long memberId) {
studyAccessValidator.validateStudyLeader(studyId, memberId);

Schedule schedule = scheduleRepository.getById(scheduleId);
schedule.stopAttendance(studyId);
}

public void checkAttendance(String encryptedToken, long memberId) {
AttendanceTokenData tokenData = decryptToken(encryptedToken);

studyAccessValidator.validateStudyMember(tokenData.studyId(), memberId);

Schedule schedule = scheduleRepository.getById(tokenData.scheduleId());
schedule.validateAttendanceCheckable();

Attendance attendance = attendanceRepository
.findByScheduleIdAndMemberInfoMemberId(tokenData.scheduleId(), memberId)
.orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_MEMBER_NOT_FOUND));

attendance.markAttendance(AttendanceStatus.PRESENT);
}

private void createAttendancesForAllMembers(long studyId, long scheduleId) {
List<StudyMember> activeMembers = studyMemberRepository
.findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_MEMBER_STATUSES);

List<Long> memberIds = activeMembers.stream()
.map(StudyMember::getMemberId)
.toList();

Map<Long, MemberInfoResponse> memberInfoMap = getMemberInfoPort.getMemberInfo(memberIds);

List<Attendance> attendances = activeMembers.stream()
.map(studyMember -> {
MemberInfoResponse info = memberInfoMap.get(studyMember.getMemberId());
MemberInfo memberInfo = MemberInfo.of(
studyMember.getMemberId(),
info.name(),
info.profileImageUrl()
);
Attendance attendance = Attendance.createPending(idGenerator.nextId(), scheduleId,
memberInfo);
if (studyMember.getStudyMemberStatus() == StudyMemberStatus.OWNER) {
attendance.markAttendance(AttendanceStatus.PRESENT);
}
return attendance;
})
.toList();

attendanceRepository.saveAll(attendances);
}

private String generateEncryptedToken(long studyId, long scheduleId) {
String plainText = String.format("%d:%d:%d", studyId, scheduleId, Instant.now().toEpochMilli());
return tokenEncryptor.encrypt(plainText);
}

private AttendanceTokenData decryptToken(String encryptedToken) {
String plainText = tokenEncryptor.decrypt(encryptedToken);
String[] parts = plainText.split(":");
if (parts.length != 3) {
throw new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN);
}
return new AttendanceTokenData(Long.parseLong(parts[0]), Long.parseLong(parts[1]));
}

private record AttendanceTokenData(long studyId, long scheduleId) {

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package kr.spot.schedule.application.event;

import kr.spot.ports.FileStoragePort;
import kr.spot.ports.dto.UploadResult;
import kr.spot.schedule.domain.Schedule;
import kr.spot.schedule.infrastructure.jpa.ScheduleRepository;
import kr.spot.schedule.infrastructure.qr.ByteArrayMultipartFile;
import kr.spot.schedule.infrastructure.qr.QrCodeGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Component
@RequiredArgsConstructor
public class AttendanceQrCodeUploadListener {

private static final String FILE_DIR = "attendance/qr/";
private static final String CONTENT_TYPE = "image/png";

private final QrCodeGenerator qrCodeGenerator;
private final FileStoragePort fileStoragePort;
private final ScheduleRepository scheduleRepository;

@Async("imageUploadExecutor")
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAttendanceStarted(AttendanceStartedEvent event) {
try {
byte[] qrCodeImage = qrCodeGenerator.generate(event.qrContent());
String filename = generateFilename(event.studyId(), event.scheduleId());
MultipartFile multipartFile = new ByteArrayMultipartFile(qrCodeImage, filename, CONTENT_TYPE);

UploadResult result = fileStoragePort.upload(multipartFile, FILE_DIR);

Schedule schedule = scheduleRepository.getById(event.scheduleId());
schedule.updateQrCodeImageUrl(result.url());

log.info("Successfully uploaded QR code image for schedule: {}", event.scheduleId());
} catch (Exception e) {
log.error("Failed to upload QR code image for schedule: {}", event.scheduleId(), e);
}
}

private String generateFilename(long studyId, long scheduleId) {
return String.format("study_%d_schedule_%d_%d.png", studyId, scheduleId,
System.currentTimeMillis());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.spot.schedule.application.event;

public record AttendanceStartedEvent(
long studyId,
long scheduleId,
String qrContent
) {

public static AttendanceStartedEvent of(long studyId, long scheduleId, String qrContent) {
return new AttendanceStartedEvent(studyId, scheduleId, qrContent);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package kr.spot.schedule.application.query;

import java.util.List;
import kr.spot.schedule.domain.Attendance;
import kr.spot.schedule.domain.Schedule;
import kr.spot.schedule.infrastructure.jpa.AttendanceRepository;
import kr.spot.schedule.infrastructure.jpa.ScheduleRepository;
import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse;
import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse;
import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse.AttendanceInfoResponse;
import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse.MemberInfoResponse;
import kr.spot.study.application.validator.StudyAccessValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class GetAttendanceService {

private final ScheduleRepository scheduleRepository;
private final AttendanceRepository attendanceRepository;
private final StudyAccessValidator studyAccessValidator;

public GetAttendanceInfoResponse getAttendanceQrCode(long studyId, long scheduleId,
long memberId) {
studyAccessValidator.validateStudyMember(studyId, memberId);

Schedule schedule = scheduleRepository.getById(scheduleId);
return GetAttendanceInfoResponse.of(
schedule.isAttendanceActive(),
schedule.getAttendanceQrCodeImageUrl()
);
}

public GetAttendanceListResponse getAttendanceList(long studyId, long scheduleId,
long memberId) {
studyAccessValidator.validateStudyMember(studyId, memberId);

List<Attendance> attendances = attendanceRepository.findAllByScheduleId(scheduleId);

List<AttendanceInfoResponse> attendanceInfos = attendances.stream()
.map(attendance -> AttendanceInfoResponse.from(
MemberInfoResponse.from(
attendance.getMemberInfo().getMemberId(),
attendance.getMemberInfo().getMemberName(),
attendance.getMemberInfo().getMemberProfileImageUrl()
),
attendance.getAttendanceStatus().name(),
attendance.getAttendedAt()
))
.toList();

return GetAttendanceListResponse.from(attendanceInfos, attendanceInfos.size());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package kr.spot.schedule.domain;

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import java.time.LocalDateTime;
import kr.spot.domain.BaseEntity;
import kr.spot.schedule.domain.enums.AttendanceStatus;
import kr.spot.schedule.domain.vo.MemberInfo;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;

@Getter
@Entity
@SQLDelete(sql = "UPDATE attendance SET status = 'INACTIVE' WHERE id = ?")
@SQLRestriction("status = 'ACTIVE'")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Attendance extends BaseEntity {

@Id
private Long id;

private Long scheduleId;

private MemberInfo memberInfo;

@Enumerated(EnumType.STRING)
private AttendanceStatus attendanceStatus;

private LocalDateTime attendedAt;

public static Attendance of(long id, long scheduleId, MemberInfo memberInfo) {
return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, null);
}

public static Attendance createPending(long id, long scheduleId, MemberInfo memberInfo) {
return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, null);
}

public void markAttendance(AttendanceStatus status) {
this.attendanceStatus = status;
this.attendedAt = LocalDateTime.now();
}
}
47 changes: 45 additions & 2 deletions modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@ public class Schedule extends BaseEntity {

private LocalDateTime endAt;

private boolean attendanceActive;

private String attendanceQrCodeImageUrl;

public static Schedule of(Long id, Long studyId, String title, String locationMemo,
LocalDateTime startAt, LocalDateTime endAt
) {
return new Schedule(id, studyId, title, locationMemo, startAt, endAt
);
return new Schedule(id, studyId, title, locationMemo, startAt, endAt, false, null);
}

public void delete(long studyId) {
Expand All @@ -55,9 +58,49 @@ public boolean isOngoing(LocalDateTime now) {
return !now.isBefore(startAt) && !now.isAfter(endAt);
}

public void startAttendance(long studyId) {
validateIsValidAccess(studyId);
validateIsOngoing();
validateIsNotAttendanceActive();
this.attendanceActive = true;
}

public void stopAttendance(long studyId) {
validateIsValidAccess(studyId);
this.attendanceActive = false;
this.attendanceQrCodeImageUrl = null;
}

public void updateQrCodeImageUrl(String url) {
this.attendanceQrCodeImageUrl = url;
}

public void validateAttendanceCheckable() {
validateIsAttendanceActive();
validateIsOngoing();
}

private void validateIsValidAccess(long studyId) {
if (studyId != this.studyId) {
throw new GeneralException(ErrorStatus._SCHEDULE_ACCESS_DENIED);
}
}

private void validateIsOngoing() {
if (!isOngoing(LocalDateTime.now())) {
throw new GeneralException(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME);
}
}

private void validateIsAttendanceActive() {
if (!this.attendanceActive) {
throw new GeneralException(ErrorStatus._ATTENDANCE_NOT_STARTED);
}
}

private void validateIsNotAttendanceActive() {
if (this.attendanceActive) {
throw new GeneralException(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED);
}
}
}
Loading