From 5b0888f47947d9abb40166d96b4b885afe4b29fb Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 21:56:00 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EC=B6=9C=EC=84=9D=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?-=20=EC=B6=9C=EC=84=9D=20=EC=B2=B4=ED=81=AC=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C,=20QR=20=EC=BD=94=EB=93=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20-=20=EC=B6=9C=EC=84=9D=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=ED=8F=AC=ED=95=A8)=20-=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 6 ++ modules/study/build.gradle | 4 + .../command/AttendanceCommandService.java | 88 +++++++++++++++++++ .../event/AttendanceQrCodeUploadListener.java | 55 ++++++++++++ .../event/AttendanceStartedEvent.java | 12 +++ .../query/GetAttendanceService.java | 29 ++++++ .../kr/spot/schedule/domain/Attendance.java | 44 ++++++++++ .../kr/spot/schedule/domain/Schedule.java | 47 +++++++++- .../domain/enums/AttendanceStatus.java | 7 ++ .../spot/schedule/domain/vo/MemberInfo.java | 26 ++++++ .../jpa/AttendanceRepository.java | 12 +++ .../qr/ByteArrayMultipartFile.java | 62 +++++++++++++ .../infrastructure/qr/QrCodeGenerator.java | 46 ++++++++++ .../command/AttendanceCommandController.java | 55 ++++++++++++ .../query/AttendanceQueryController.java | 48 ++++++++++ .../query/dto/GetAttendanceInfoResponse.java | 11 +++ .../query/dto/GetAttendanceListResponse.java | 39 ++++++++ 17 files changed, 589 insertions(+), 2 deletions(-) create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index e7a8a4a..c08036b 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -66,6 +66,12 @@ 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", "이미 출석체크를 완료했습니다."), // 투두 관련 _TODO_NOT_FOUND(404, "TODO404", "투두를 찾을 수 없습니다."), diff --git a/modules/study/build.gradle b/modules/study/build.gradle index 2eaa92b..d613c9f 100644 --- a/modules/study/build.gradle +++ b/modules/study/build.gradle @@ -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" diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java new file mode 100644 index 0000000..1d98af3 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java @@ -0,0 +1,88 @@ +package kr.spot.schedule.application.command; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +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.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AttendanceCommandService { + + private final IdGenerator idGenerator; + private final ScheduleRepository scheduleRepository; + private final AttendanceRepository attendanceRepository; + private final StudyAccessValidator studyAccessValidator; + private final GetMemberInfoPort getMemberInfoPort; + private final ApplicationEventPublisher eventPublisher; + + @Value("${app.base-url}") + private String baseUrl; + + public void startAttendance(long studyId, long scheduleId, long memberId) { + studyAccessValidator.validateStudyLeader(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.startAttendance(studyId); + + String qrContent = generateQrContent(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(long studyId, long scheduleId, long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.validateAttendanceCheckable(); + + if (attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(scheduleId, memberId)) { + throw new GeneralException(ErrorStatus._ATTENDANCE_ALREADY_CHECKED); + } + + MemberInfo memberInfo = getMemberInfo(memberId); + Attendance attendance = Attendance.of(idGenerator.nextId(), scheduleId, memberInfo); + attendance.markAttendance(AttendanceStatus.PRESENT); + attendanceRepository.save(attendance); + } + + private MemberInfo getMemberInfo(long memberId) { + Map memberInfoMap = getMemberInfoPort.getMemberInfo( + List.of(memberId)); + MemberInfoResponse info = memberInfoMap.get(memberId); + if (info == null) { + throw new GeneralException(ErrorStatus._MEMBER_NOT_FOUND); + } + return MemberInfo.of(memberId, info.name(), info.profileImageUrl()); + } + + private String generateQrContent(long studyId, long scheduleId) { + String token = UUID.randomUUID().toString(); + return String.format("%s/api/studies/%d/schedules/%d/attendance/check?token=%s", + baseUrl, studyId, scheduleId, token); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java new file mode 100644 index 0000000..3bc6750 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java @@ -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()); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java new file mode 100644 index 0000000..e1a70fb --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java @@ -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); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java new file mode 100644 index 0000000..1fed46d --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java @@ -0,0 +1,29 @@ +package kr.spot.schedule.application.query; + +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +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 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() + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java new file mode 100644 index 0000000..baa4a48 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java @@ -0,0 +1,44 @@ +package kr.spot.schedule.domain; + +import jakarta.persistence.Entity; +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; + + private AttendanceStatus attendanceStatus; + + private LocalDateTime attendedAt; + + public static Attendance of(Long id, Long scheduleId, MemberInfo memberInfo) { + return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, + LocalDateTime.now()); + } + + public void markAttendance(AttendanceStatus status) { + this.attendanceStatus = status; + this.attendedAt = LocalDateTime.now(); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java index 85a7ccc..fda2d53 100644 --- a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java @@ -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) { @@ -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); + } + } } diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java b/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java new file mode 100644 index 0000000..b17fd2c --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java @@ -0,0 +1,7 @@ +package kr.spot.schedule.domain.enums; + +public enum AttendanceStatus { + PRESENT, // 출석 + ABSENT, // 결석 + UNDECIDED // 미결 +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java b/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java new file mode 100644 index 0000000..128adf2 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java @@ -0,0 +1,26 @@ +package kr.spot.schedule.domain.vo; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class MemberInfo { + + private Long memberId; + + private String memberName; + + private String memberProfileImageUrl; + + public static MemberInfo of(Long memberId, String memberName, String memberProfileImageUrl) { + return new MemberInfo(memberId, memberName, memberProfileImageUrl); + } +} \ No newline at end of file diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java new file mode 100644 index 0000000..4cb399d --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java @@ -0,0 +1,12 @@ +package kr.spot.schedule.infrastructure.jpa; + +import java.util.Optional; +import kr.spot.schedule.domain.Attendance; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendanceRepository extends JpaRepository { + + Optional findByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); + + boolean existsByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java new file mode 100644 index 0000000..49eba32 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java @@ -0,0 +1,62 @@ +package kr.spot.schedule.infrastructure.qr; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import org.springframework.web.multipart.MultipartFile; + +public class ByteArrayMultipartFile implements MultipartFile { + + private final byte[] content; + private final String name; + private final String originalFilename; + private final String contentType; + + public ByteArrayMultipartFile(byte[] content, String filename, String contentType) { + this.content = content; + this.name = filename; + this.originalFilename = filename; + this.contentType = contentType; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content == null || content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() throws IOException { + return content; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + throw new UnsupportedOperationException("transferTo is not supported"); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java new file mode 100644 index 0000000..7806b78 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java @@ -0,0 +1,46 @@ +package kr.spot.schedule.infrastructure.qr; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Map; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.springframework.stereotype.Component; + +@Component +public class QrCodeGenerator { + + private static final int DEFAULT_WIDTH = 300; + private static final int DEFAULT_HEIGHT = 300; + private static final String IMAGE_FORMAT = "PNG"; + + public byte[] generate(String content) { + return generate(content, DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + public byte[] generate(String content, int width, int height) { + try { + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + Map hints = Map.of( + EncodeHintType.CHARACTER_SET, "UTF-8", + EncodeHintType.MARGIN, 1 + ); + + BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, width, height, + hints); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + MatrixToImageWriter.writeToStream(bitMatrix, IMAGE_FORMAT, outputStream); + + return outputStream.toByteArray(); + } catch (WriterException | IOException e) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java new file mode 100644 index 0000000..f62be07 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java @@ -0,0 +1,55 @@ +package kr.spot.schedule.presentation.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.command.AttendanceCommandService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 일정 - 출석체크") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") +@RequiredArgsConstructor +public class AttendanceCommandController { + + private final AttendanceCommandService attendanceCommandService; + + @Operation(summary = "출석체크 시작", description = "스터디장이 출석체크를 시작합니다. QR 코드 이미지는 비동기로 생성됩니다.") + @PostMapping + public ResponseEntity> startAttendanceCheck( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.startAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "출석체크 종료", description = "스터디장이 출석체크를 종료합니다.") + @DeleteMapping + public ResponseEntity> stopAttendanceCheck( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.stopAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "출석체크 처리", description = "QR 코드를 통해 출석체크를 처리합니다.") + @PostMapping("/check") + public ResponseEntity> checkAttendance( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.checkAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java new file mode 100644 index 0000000..656090c --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java @@ -0,0 +1,48 @@ +package kr.spot.schedule.presentation.query; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.query.GetAttendanceService; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 일정 - 출석체크") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") +@RequiredArgsConstructor +public class AttendanceQueryController { + + private final GetAttendanceService getAttendanceService; + + @Operation(summary = "출석 목록 조회", description = "해당 일정의 출석 목록을 조회합니다.") + @GetMapping + public ResponseEntity> getAttendanceList( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + } + + @Operation(summary = "출석체크 QR 코드 조회", description = "출석체크용 QR 코드 이미지 URL을 조회합니다.") + @GetMapping("/qr") + public ResponseEntity> getAttendanceQrCode( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + GetAttendanceInfoResponse response = getAttendanceService.getAttendanceQrCode(studyId, + scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java new file mode 100644 index 0000000..d4cb9c7 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java @@ -0,0 +1,11 @@ +package kr.spot.schedule.presentation.query.dto; + +public record GetAttendanceInfoResponse( + boolean attendanceActive, + String qrCodeImageUrl +) { + + public static GetAttendanceInfoResponse of(boolean attendanceActive, String qrCodeImageUrl) { + return new GetAttendanceInfoResponse(attendanceActive, qrCodeImageUrl); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java new file mode 100644 index 0000000..262e03c --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java @@ -0,0 +1,39 @@ +package kr.spot.schedule.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record GetAttendanceListResponse( + List attendances, + int totalCount +) { + + public static GetAttendanceListResponse from(List attendances, + int totalCount) { + return new GetAttendanceListResponse(attendances, totalCount); + } + + public record AttendanceInfoResponse( + MemberInfoResponse member, + String attendanceStatus, + LocalDateTime attendedAt + ) { + + public static AttendanceInfoResponse from(MemberInfoResponse member, String attendanceStatus, + LocalDateTime attendedAt) { + return new AttendanceInfoResponse(member, attendanceStatus, attendedAt); + } + } + + public record MemberInfoResponse( + Long memberId, + String memberName, + String memberProfileImageUrl + ) { + + public static MemberInfoResponse from(Long memberId, String memberName, + String memberProfileImageUrl) { + return new MemberInfoResponse(memberId, memberName, memberProfileImageUrl); + } + } +} From a3246989c5d90a3ffaf18122f0d18cb1a1d5eddf Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:20:24 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EC=B6=9C=EC=84=9D=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/AttendanceCommandServiceTest.java | 276 ++++++++++++++++++ .../query/GetAttendanceServiceTest.java | 117 ++++++++ .../schedule/common/AttendanceFixture.java | 73 +++++ .../spot/schedule/domain/AttendanceTest.java | 82 ++++++ .../domain/ScheduleAttendanceTest.java | 218 ++++++++++++++ 5 files changed, 766 insertions(+) create mode 100644 modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java diff --git a/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java new file mode 100644 index 0000000..7c7a3f8 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java @@ -0,0 +1,276 @@ +package kr.spot.schedule.application.command; + +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_NAME; +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_PROFILE_URL; +import static kr.spot.schedule.common.AttendanceFixture.OTHER_MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; +import static kr.spot.schedule.common.AttendanceFixture.pastSchedule; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AttendanceCommandServiceTest { + + @Mock + IdGenerator idGenerator; + @Mock + ScheduleRepository scheduleRepository; + @Mock + AttendanceRepository attendanceRepository; + @Mock + StudyAccessValidator studyAccessValidator; + @Mock + GetMemberInfoPort getMemberInfoPort; + @Mock + ApplicationEventPublisher eventPublisher; + + AttendanceCommandService service; + + @BeforeEach + void setUp() { + service = new AttendanceCommandService( + idGenerator, + scheduleRepository, + attendanceRepository, + studyAccessValidator, + getMemberInfoPort, + eventPublisher + ); + ReflectionTestUtils.setField(service, "baseUrl", "https://spot.com"); + } + + @Nested + @DisplayName("출석체크 시작") + class StartAttendance { + + @Test + @DisplayName("스터디장이 출석체크를 시작하면 일정이 출석체크 활성 상태가 된다") + void should_activateAttendance_when_leaderStartsAttendance() { + // given + Schedule schedule = ongoingSchedule(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(schedule.isAttendanceActive()).isTrue(); + verify(eventPublisher).publishEvent(any(Object.class)); + } + + @Test + @DisplayName("스터디장이 아니면 출석체크를 시작할 수 없다") + void should_throwException_when_notStudyLeader() { + // given + doThrow(new GeneralException(ErrorStatus._ONLY_LEADER_CAN_ACCESS)) + .when(studyAccessValidator).validateStudyLeader(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy(() -> service.startAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ONLY_LEADER_CAN_ACCESS); + } + + @Test + @DisplayName("이미 출석체크가 진행 중이면 예외를 던진다") + void should_throwException_when_attendanceAlreadyActive() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED); + } + + @Test + @DisplayName("일정 시간이 아니면 출석체크를 시작할 수 없다") + void should_throwException_when_scheduleNotOngoing() { + // given + Schedule schedule = pastSchedule(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + } + + @Nested + @DisplayName("출석체크 종료") + class StopAttendance { + + @Test + @DisplayName("스터디장이 출석체크를 종료하면 비활성 상태가 된다") + void should_deactivateAttendance_when_leaderStopsAttendance() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + service.stopAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(schedule.isAttendanceActive()).isFalse(); + assertThat(schedule.getAttendanceQrCodeImageUrl()).isNull(); + } + + @Test + @DisplayName("스터디장이 아니면 출석체크를 종료할 수 없다") + void should_throwException_when_notStudyLeader() { + // given + doThrow(new GeneralException(ErrorStatus._ONLY_LEADER_CAN_ACCESS)) + .when(studyAccessValidator).validateStudyLeader(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy(() -> service.stopAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ONLY_LEADER_CAN_ACCESS); + } + } + + @Nested + @DisplayName("출석체크 처리") + class CheckAttendance { + + @Test + @DisplayName("스터디 멤버가 출석체크를 하면 출석 기록이 저장된다") + void should_saveAttendance_when_memberChecksIn() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + MemberInfoResponse memberInfo = new MemberInfoResponse(MEMBER_NAME, MEMBER_PROFILE_URL); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + when(attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(false); + when(getMemberInfoPort.getMemberInfo(any())).thenReturn(Map.of(MEMBER_ID, memberInfo)); + when(idGenerator.nextId()).thenReturn(200L); + + // when + service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(Attendance.class); + verify(attendanceRepository).save(captor.capture()); + + Attendance saved = captor.getValue(); + assertThat(saved.getScheduleId()).isEqualTo(SCHEDULE_ID); + assertThat(saved.getMemberInfo().getMemberId()).isEqualTo(MEMBER_ID); + assertThat(saved.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + } + + @Test + @DisplayName("스터디 멤버가 아니면 출석체크를 할 수 없다") + void should_throwException_when_notStudyMember() { + // given + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); + } + + @Test + @DisplayName("출석체크가 시작되지 않았으면 출석할 수 없다") + void should_throwException_when_attendanceNotStarted() { + // given + Schedule schedule = ongoingSchedule(); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_STARTED); + } + + @Test + @DisplayName("이미 출석체크를 했으면 중복 출석할 수 없다") + void should_throwException_when_alreadyCheckedIn() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + when(attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(true); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_ALREADY_CHECKED); + } + + @Test + @DisplayName("일정 시간이 아니면 출석체크를 할 수 없다") + void should_throwException_when_scheduleTimeExpired() { + // given + Schedule schedule = pastSchedule(); + // 과거 일정이지만 강제로 출석체크 활성화 상태로 설정 (테스트용) + ReflectionTestUtils.setField(schedule, "attendanceActive", true); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java new file mode 100644 index 0000000..043ad95 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java @@ -0,0 +1,117 @@ +package kr.spot.schedule.application.query; + +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.OTHER_MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.QR_CODE_URL; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class GetAttendanceServiceTest { + + @Mock + ScheduleRepository scheduleRepository; + @Mock + StudyAccessValidator studyAccessValidator; + + GetAttendanceService service; + + @BeforeEach + void setUp() { + service = new GetAttendanceService(scheduleRepository, studyAccessValidator); + } + + @Nested + @DisplayName("출석체크 QR 코드 조회") + class GetAttendanceQrCode { + + @Test + @DisplayName("출석체크가 활성화된 경우 QR 코드 URL을 반환한다") + void should_returnQrCodeUrl_when_attendanceActive() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + ReflectionTestUtils.setField(schedule, "attendanceQrCodeImageUrl", QR_CODE_URL); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + GetAttendanceInfoResponse response = service.getAttendanceQrCode( + STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.attendanceActive()).isTrue(); + assertThat(response.qrCodeImageUrl()).isEqualTo(QR_CODE_URL); + } + + @Test + @DisplayName("출석체크가 비활성화된 경우 isActive가 false이다") + void should_returnInactive_when_attendanceNotActive() { + // given + Schedule schedule = ongoingSchedule(); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + GetAttendanceInfoResponse response = service.getAttendanceQrCode( + STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.attendanceActive()).isFalse(); + assertThat(response.qrCodeImageUrl()).isNull(); + } + + @Test + @DisplayName("스터디 멤버가 아니면 QR 코드를 조회할 수 없다") + void should_throwException_when_notStudyMember() { + // given + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy( + () -> service.getAttendanceQrCode(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); + } + + @Test + @DisplayName("존재하지 않는 일정은 조회할 수 없다") + void should_throwException_when_scheduleNotFound() { + // given + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)) + .thenThrow(new GeneralException(ErrorStatus._SCHEDULE_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> service.getAttendanceQrCode(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_NOT_FOUND); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java b/modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java new file mode 100644 index 0000000..030b633 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java @@ -0,0 +1,73 @@ +package kr.spot.schedule.common; + +import java.time.LocalDateTime; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.vo.MemberInfo; + +public class AttendanceFixture { + + public static final long STUDY_ID = 1L; + public static final long SCHEDULE_ID = 100L; + public static final long ATTENDANCE_ID = 200L; + public static final long MEMBER_ID = 42L; + public static final long OTHER_MEMBER_ID = 99L; + + public static final String MEMBER_NAME = "테스터"; + public static final String MEMBER_PROFILE_URL = "https://example.com/profile.jpg"; + public static final String QR_CODE_URL = "https://example.com/qr.png"; + + public static MemberInfo memberInfo() { + return MemberInfo.of(MEMBER_ID, MEMBER_NAME, MEMBER_PROFILE_URL); + } + + public static MemberInfo memberInfo(long memberId) { + return MemberInfo.of(memberId, MEMBER_NAME, MEMBER_PROFILE_URL); + } + + public static Attendance attendance() { + return Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + } + + public static Schedule ongoingSchedule() { + LocalDateTime now = LocalDateTime.now(); + return Schedule.of( + SCHEDULE_ID, + STUDY_ID, + "테스트 일정", + "테스트 장소", + now.minusHours(1), + now.plusHours(1) + ); + } + + public static Schedule ongoingScheduleWithAttendance() { + Schedule schedule = ongoingSchedule(); + schedule.startAttendance(STUDY_ID); + return schedule; + } + + public static Schedule pastSchedule() { + LocalDateTime now = LocalDateTime.now(); + return Schedule.of( + SCHEDULE_ID, + STUDY_ID, + "지난 일정", + "테스트 장소", + now.minusHours(3), + now.minusHours(1) + ); + } + + public static Schedule futureSchedule() { + LocalDateTime now = LocalDateTime.now(); + return Schedule.of( + SCHEDULE_ID, + STUDY_ID, + "미래 일정", + "테스트 장소", + now.plusHours(1), + now.plusHours(3) + ); + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java new file mode 100644 index 0000000..2fdaef1 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java @@ -0,0 +1,82 @@ +package kr.spot.schedule.domain; + +import static kr.spot.schedule.common.AttendanceFixture.ATTENDANCE_ID; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.memberInfo; +import static org.assertj.core.api.Assertions.assertThat; + +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.domain.vo.MemberInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AttendanceTest { + + @Nested + @DisplayName("출석 기록 생성") + class CreateAttendance { + + @Test + @DisplayName("출석 기록 생성 시 초기 상태는 UNDECIDED이다") + void should_createWithUndecidedStatus() { + // given + MemberInfo member = memberInfo(); + + // when + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, member); + + // then + assertThat(attendance.getId()).isEqualTo(ATTENDANCE_ID); + assertThat(attendance.getScheduleId()).isEqualTo(SCHEDULE_ID); + assertThat(attendance.getMemberInfo()).isEqualTo(member); + assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.UNDECIDED); + assertThat(attendance.getAttendedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("출석 상태 변경") + class MarkAttendance { + + @Test + @DisplayName("출석으로 상태를 변경할 수 있다") + void should_markAsPresent() { + // given + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + + // when + attendance.markAttendance(AttendanceStatus.PRESENT); + + // then + assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + } + + @Test + @DisplayName("결석으로 상태를 변경할 수 있다") + void should_markAsAbsent() { + // given + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + + // when + attendance.markAttendance(AttendanceStatus.ABSENT); + + // then + assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.ABSENT); + } + + @Test + @DisplayName("출석 상태 변경 시 출석 시간이 갱신된다") + void should_updateAttendedAt_when_markAttendance() { + // given + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + var originalTime = attendance.getAttendedAt(); + + // when + attendance.markAttendance(AttendanceStatus.PRESENT); + + // then + assertThat(attendance.getAttendedAt()).isAfterOrEqualTo(originalTime); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java new file mode 100644 index 0000000..b93220a --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java @@ -0,0 +1,218 @@ +package kr.spot.schedule.domain; + +import static kr.spot.schedule.common.AttendanceFixture.QR_CODE_URL; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.futureSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; +import static kr.spot.schedule.common.AttendanceFixture.pastSchedule; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ScheduleAttendanceTest { + + @Nested + @DisplayName("출석체크 시작") + class StartAttendance { + + @Test + @DisplayName("진행 중인 일정에서 출석체크를 시작할 수 있다") + void should_startAttendance_when_scheduleIsOngoing() { + // given + Schedule schedule = ongoingSchedule(); + + // when + schedule.startAttendance(STUDY_ID); + + // then + assertThat(schedule.isAttendanceActive()).isTrue(); + } + + @Test + @DisplayName("다른 스터디의 일정에서는 출석체크를 시작할 수 없다") + void should_throwException_when_differentStudyId() { + // given + Schedule schedule = ongoingSchedule(); + long wrongStudyId = 999L; + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(wrongStudyId)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_ACCESS_DENIED); + } + + @Test + @DisplayName("일정 시간 외에는 출석체크를 시작할 수 없다") + void should_throwException_when_scheduleNotOngoing() { + // given + Schedule schedule = pastSchedule(); + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(STUDY_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + + @Test + @DisplayName("미래 일정에서는 출석체크를 시작할 수 없다") + void should_throwException_when_scheduleInFuture() { + // given + Schedule schedule = futureSchedule(); + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(STUDY_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + + @Test + @DisplayName("이미 출석체크가 진행 중이면 예외를 던진다") + void should_throwException_when_attendanceAlreadyActive() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(STUDY_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED); + } + } + + @Nested + @DisplayName("출석체크 종료") + class StopAttendance { + + @Test + @DisplayName("출석체크를 종료하면 비활성 상태가 된다") + void should_deactivateAttendance_when_stopped() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + // when + schedule.stopAttendance(STUDY_ID); + + // then + assertThat(schedule.isAttendanceActive()).isFalse(); + } + + @Test + @DisplayName("출석체크 종료 시 QR 코드 URL이 초기화된다") + void should_clearQrCodeUrl_when_stopped() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + schedule.updateQrCodeImageUrl(QR_CODE_URL); + + // when + schedule.stopAttendance(STUDY_ID); + + // then + assertThat(schedule.getAttendanceQrCodeImageUrl()).isNull(); + } + + @Test + @DisplayName("다른 스터디의 일정은 출석체크를 종료할 수 없다") + void should_throwException_when_differentStudyId() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + long wrongStudyId = 999L; + + // when & then + assertThatThrownBy(() -> schedule.stopAttendance(wrongStudyId)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_ACCESS_DENIED); + } + } + + @Nested + @DisplayName("출석체크 가능 여부 검증") + class ValidateAttendanceCheckable { + + @Test + @DisplayName("출석체크가 활성화되고 진행 중인 일정이면 검증에 성공한다") + void should_passValidation_when_attendanceActiveAndOngoing() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + // when & then (no exception thrown) + schedule.validateAttendanceCheckable(); + } + + @Test + @DisplayName("출석체크가 비활성화되어 있으면 검증에 실패한다") + void should_failValidation_when_attendanceNotActive() { + // given + Schedule schedule = ongoingSchedule(); + + // when & then + assertThatThrownBy(schedule::validateAttendanceCheckable) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_STARTED); + } + } + + @Nested + @DisplayName("QR 코드 URL 업데이트") + class UpdateQrCodeImageUrl { + + @Test + @DisplayName("QR 코드 URL을 업데이트할 수 있다") + void should_updateQrCodeUrl() { + // given + Schedule schedule = ongoingSchedule(); + + // when + schedule.updateQrCodeImageUrl(QR_CODE_URL); + + // then + assertThat(schedule.getAttendanceQrCodeImageUrl()).isEqualTo(QR_CODE_URL); + } + } + + @Nested + @DisplayName("일정 진행 중 확인") + class IsOngoing { + + @Test + @DisplayName("현재 시간이 일정 시간 내에 있으면 true를 반환한다") + void should_returnTrue_when_currentTimeWithinSchedule() { + // given + Schedule schedule = ongoingSchedule(); + + // when & then + assertThat(schedule.isOngoing(java.time.LocalDateTime.now())).isTrue(); + } + + @Test + @DisplayName("일정 시간이 지났으면 false를 반환한다") + void should_returnFalse_when_scheduleEnded() { + // given + Schedule schedule = pastSchedule(); + + // when & then + assertThat(schedule.isOngoing(java.time.LocalDateTime.now())).isFalse(); + } + + @Test + @DisplayName("일정 시간 전이면 false를 반환한다") + void should_returnFalse_when_scheduleNotStarted() { + // given + Schedule schedule = futureSchedule(); + + // when & then + assertThat(schedule.isOngoing(java.time.LocalDateTime.now())).isFalse(); + } + } +} From 62cde207026ddf2708352bc887148dbdb25a2d96 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:43:50 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EC=B6=9C=EC=84=9D=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20-=20QR=EC=97=90=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EB=84=A3=EB=8A=94=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=86=A0=ED=81=B0=EC=9D=84=20=EB=84=A3?= =?UTF-8?q?=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20=ED=86=A0=ED=81=B0=EC=97=90=EB=8A=94=20studyId?= =?UTF-8?q?=EC=99=80=20scheduleId=EB=A5=BC=20=EC=95=94=ED=98=B8=ED=99=94?= =?UTF-8?q?=20=ED=95=98=EC=97=AC=20=EC=A0=80=EC=9E=A5=20-=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=ED=86=A0=ED=81=B0=EC=9D=84=20=ED=99=9C=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=B6=9C=EC=84=9D=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 1 + .../command/AttendanceCommandService.java | 85 +++++++++++++------ .../query/GetAttendanceService.java | 28 ++++++ .../kr/spot/schedule/domain/Attendance.java | 12 ++- .../crypto/AttendanceTokenEncryptor.java | 73 ++++++++++++++++ .../jpa/AttendanceRepository.java | 3 + .../command/AttendanceCommandController.java | 4 +- .../query/AttendanceQueryController.java | 4 +- .../associations/StudyMemberRepository.java | 3 + 9 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index c08036b..058033b 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -72,6 +72,7 @@ public enum ErrorStatus implements BaseErrorCode { _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", "투두를 찾을 수 없습니다."), diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java index 1d98af3..d007276 100644 --- a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java @@ -1,8 +1,8 @@ package kr.spot.schedule.application.command; +import java.time.Instant; import java.util.List; import java.util.Map; -import java.util.UUID; import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; @@ -13,11 +13,14 @@ 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.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,15 +30,17 @@ @RequiredArgsConstructor public class AttendanceCommandService { + private static final List 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; - - @Value("${app.base-url}") - private String baseUrl; + private final AttendanceTokenEncryptor tokenEncryptor; public void startAttendance(long studyId, long scheduleId, long memberId) { studyAccessValidator.validateStudyLeader(studyId, memberId); @@ -43,7 +48,9 @@ public void startAttendance(long studyId, long scheduleId, long memberId) { Schedule schedule = scheduleRepository.getById(scheduleId); schedule.startAttendance(studyId); - String qrContent = generateQrContent(studyId, scheduleId); + createAttendancesForAllMembers(studyId, scheduleId); + + String qrContent = generateEncryptedToken(studyId, scheduleId); eventPublisher.publishEvent(AttendanceStartedEvent.of(studyId, scheduleId, qrContent)); } @@ -54,35 +61,61 @@ public void stopAttendance(long studyId, long scheduleId, long memberId) { schedule.stopAttendance(studyId); } - public void checkAttendance(long studyId, long scheduleId, long memberId) { - studyAccessValidator.validateStudyMember(studyId, memberId); + public void checkAttendance(String encryptedToken, long memberId) { + AttendanceTokenData tokenData = decryptToken(encryptedToken); - Schedule schedule = scheduleRepository.getById(scheduleId); + studyAccessValidator.validateStudyMember(tokenData.studyId(), memberId); + + Schedule schedule = scheduleRepository.getById(tokenData.scheduleId()); schedule.validateAttendanceCheckable(); - if (attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(scheduleId, memberId)) { - throw new GeneralException(ErrorStatus._ATTENDANCE_ALREADY_CHECKED); - } + Attendance attendance = attendanceRepository + .findByScheduleIdAndMemberInfoMemberId(tokenData.scheduleId(), memberId) + .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_MEMBER_NOT_FOUND)); - MemberInfo memberInfo = getMemberInfo(memberId); - Attendance attendance = Attendance.of(idGenerator.nextId(), scheduleId, memberInfo); attendance.markAttendance(AttendanceStatus.PRESENT); - attendanceRepository.save(attendance); } - private MemberInfo getMemberInfo(long memberId) { - Map memberInfoMap = getMemberInfoPort.getMemberInfo( - List.of(memberId)); - MemberInfoResponse info = memberInfoMap.get(memberId); - if (info == null) { - throw new GeneralException(ErrorStatus._MEMBER_NOT_FOUND); + private void createAttendancesForAllMembers(long studyId, long scheduleId) { + List activeMembers = studyMemberRepository + .findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_MEMBER_STATUSES); + + List memberIds = activeMembers.stream() + .map(StudyMember::getMemberId) + .toList(); + + Map memberInfoMap = getMemberInfoPort.getMemberInfo(memberIds); + + List attendances = activeMembers.stream() + .map(studyMember -> { + MemberInfoResponse info = memberInfoMap.get(studyMember.getMemberId()); + MemberInfo memberInfo = MemberInfo.of( + studyMember.getMemberId(), + info.name(), + info.profileImageUrl() + ); + return Attendance.createPending(idGenerator.nextId(), scheduleId, memberInfo); + }) + .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 MemberInfo.of(memberId, info.name(), info.profileImageUrl()); + return new AttendanceTokenData(Long.parseLong(parts[0]), Long.parseLong(parts[1])); } - private String generateQrContent(long studyId, long scheduleId) { - String token = UUID.randomUUID().toString(); - return String.format("%s/api/studies/%d/schedules/%d/attendance/check?token=%s", - baseUrl, studyId, scheduleId, token); + private record AttendanceTokenData(long studyId, long scheduleId) { + } } diff --git a/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java index 1fed46d..e944aaf 100644 --- a/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java +++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java @@ -1,8 +1,14 @@ 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; @@ -14,6 +20,7 @@ public class GetAttendanceService { private final ScheduleRepository scheduleRepository; + private final AttendanceRepository attendanceRepository; private final StudyAccessValidator studyAccessValidator; public GetAttendanceInfoResponse getAttendanceQrCode(long studyId, long scheduleId, @@ -26,4 +33,25 @@ public GetAttendanceInfoResponse getAttendanceQrCode(long studyId, long schedule schedule.getAttendanceQrCodeImageUrl() ); } + + public GetAttendanceListResponse getAttendanceList(long studyId, long scheduleId, + long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + List attendances = attendanceRepository.findAllByScheduleId(scheduleId); + + List 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()); + } } diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java index baa4a48..7952019 100644 --- a/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java @@ -1,6 +1,8 @@ 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; @@ -28,13 +30,17 @@ public class Attendance extends BaseEntity { 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, - LocalDateTime.now()); + 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) { diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java new file mode 100644 index 0000000..ed387a7 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java @@ -0,0 +1,73 @@ +package kr.spot.schedule.infrastructure.crypto; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AttendanceTokenEncryptor { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; + private static final int GCM_IV_LENGTH = 12; + + private final SecretKeySpec secretKey; + + public AttendanceTokenEncryptor( + @Value("${app.attendance.encryption-key}") String encryptionKey) { + byte[] keyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length != 16 && keyBytes.length != 24 && keyBytes.length != 32) { + throw new IllegalArgumentException("AES key must be 16, 24, or 32 bytes"); + } + this.secretKey = new SecretKeySpec(keyBytes, "AES"); + } + + public String encrypt(String plainText) { + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + byte[] iv = generateIv(); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec); + + byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + encryptedBytes.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(encryptedBytes, 0, combined, iv.length, encryptedBytes.length); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(combined); + } catch (Exception e) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } + + public String decrypt(String encryptedText) { + try { + byte[] combined = Base64.getUrlDecoder().decode(encryptedText); + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] encryptedBytes = new byte[combined.length - GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH); + System.arraycopy(combined, GCM_IV_LENGTH, encryptedBytes, 0, encryptedBytes.length); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec); + + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + return new String(decryptedBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN); + } + } + + private byte[] generateIv() { + byte[] iv = new byte[GCM_IV_LENGTH]; + new java.security.SecureRandom().nextBytes(iv); + return iv; + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java index 4cb399d..188b44e 100644 --- a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java @@ -1,5 +1,6 @@ package kr.spot.schedule.infrastructure.jpa; +import java.util.List; import java.util.Optional; import kr.spot.schedule.domain.Attendance; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +10,6 @@ public interface AttendanceRepository extends JpaRepository { Optional findByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); boolean existsByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); + + List findAllByScheduleId(Long scheduleId); } diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java index f62be07..65177ca 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "스터디 일정 - 출석체크") @@ -48,8 +49,9 @@ public ResponseEntity> stopAttendanceCheck( public ResponseEntity> checkAttendance( @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "암호화된 출석 토큰", required = true) @RequestParam String token, @CurrentMember @Parameter(hidden = true) Long memberId) { - attendanceCommandService.checkAttendance(studyId, scheduleId, memberId); + attendanceCommandService.checkAttendance(token, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } } diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java index 656090c..1620a3d 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java @@ -31,7 +31,9 @@ public ResponseEntity> getAttendanceList( @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId ) { - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + GetAttendanceListResponse response = getAttendanceService.getAttendanceList(studyId, + scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } @Operation(summary = "출석체크 QR 코드 조회", description = "출석체크용 QR 코드 이미지 URL을 조회합니다.") diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java index 76649a6..15f826e 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java @@ -17,4 +17,7 @@ default StudyMember getStudyMemberById(Long studyMemberId) { boolean existsByStudyIdAndMemberIdAndStudyMemberStatusIn(Long studyId, Long memberId, List studyMemberStatuses); + List findAllByStudyIdAndStudyMemberStatusIn(Long studyId, + List studyMemberStatuses); + } From 86ef197a618ff4e223cf1e162cf908835d3d1b0f Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:48:14 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EC=9E=A5=EC=9D=98=20=EA=B2=BD=EC=9A=B0,=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=20=EC=8B=9C=EC=9E=91=20=EC=8B=9C=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/command/AttendanceCommandService.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java index d007276..ef6658b 100644 --- a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java @@ -94,7 +94,12 @@ private void createAttendancesForAllMembers(long studyId, long scheduleId) { info.name(), info.profileImageUrl() ); - return Attendance.createPending(idGenerator.nextId(), scheduleId, memberInfo); + Attendance attendance = Attendance.createPending(idGenerator.nextId(), scheduleId, + memberInfo); + if (studyMember.getStudyMemberStatus() == StudyMemberStatus.OWNER) { + attendance.markAttendance(AttendanceStatus.PRESENT); + } + return attendance; }) .toList(); From e8c8ab724e9d5f54063af5500c46b59dae9e1b9c Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:48:35 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[Feature]=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/AttendanceCommandServiceTest.java | 120 +++++++++++++----- .../query/GetAttendanceServiceTest.java | 67 +++++++++- 2 files changed, 156 insertions(+), 31 deletions(-) diff --git a/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java index 7c7a3f8..5d18f3e 100644 --- a/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java +++ b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java @@ -6,19 +6,22 @@ import static kr.spot.schedule.common.AttendanceFixture.OTHER_MEMBER_ID; import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.attendance; import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; import static kr.spot.schedule.common.AttendanceFixture.pastSchedule; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.List; import java.util.Map; +import java.util.Optional; import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; @@ -27,9 +30,13 @@ import kr.spot.schedule.domain.Attendance; import kr.spot.schedule.domain.Schedule; import kr.spot.schedule.domain.enums.AttendanceStatus; +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 org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -44,6 +51,9 @@ @ExtendWith(MockitoExtension.class) class AttendanceCommandServiceTest { + private static final String ENCRYPTED_TOKEN = "encrypted_token"; + private static final String DECRYPTED_TOKEN = STUDY_ID + ":" + SCHEDULE_ID + ":1234567890"; + @Mock IdGenerator idGenerator; @Mock @@ -51,11 +61,15 @@ class AttendanceCommandServiceTest { @Mock AttendanceRepository attendanceRepository; @Mock + StudyMemberRepository studyMemberRepository; + @Mock StudyAccessValidator studyAccessValidator; @Mock GetMemberInfoPort getMemberInfoPort; @Mock ApplicationEventPublisher eventPublisher; + @Mock + AttendanceTokenEncryptor tokenEncryptor; AttendanceCommandService service; @@ -65,11 +79,12 @@ void setUp() { idGenerator, scheduleRepository, attendanceRepository, + studyMemberRepository, studyAccessValidator, getMemberInfoPort, - eventPublisher + eventPublisher, + tokenEncryptor ); - ReflectionTestUtils.setField(service, "baseUrl", "https://spot.com"); } @Nested @@ -77,13 +92,23 @@ void setUp() { class StartAttendance { @Test - @DisplayName("스터디장이 출석체크를 시작하면 일정이 출석체크 활성 상태가 된다") - void should_activateAttendance_when_leaderStartsAttendance() { + @DisplayName("스터디장이 출석체크를 시작하면 일정이 출석체크 활성 상태가 되고 모든 멤버의 출석 기록이 생성된다") + void should_activateAttendanceAndCreateRecords_when_leaderStartsAttendance() { // given Schedule schedule = ongoingSchedule(); + StudyMember owner = StudyMember.create(1L, STUDY_ID, MEMBER_ID); + StudyMember member = createApprovedMember(2L, STUDY_ID, OTHER_MEMBER_ID); + MemberInfoResponse ownerInfo = new MemberInfoResponse(MEMBER_NAME, MEMBER_PROFILE_URL); + MemberInfoResponse memberInfo = new MemberInfoResponse("멤버", MEMBER_PROFILE_URL); doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + when(studyMemberRepository.findAllByStudyIdAndStudyMemberStatusIn(any(), anyList())) + .thenReturn(List.of(owner, member)); + when(getMemberInfoPort.getMemberInfo(anyList())) + .thenReturn(Map.of(MEMBER_ID, ownerInfo, OTHER_MEMBER_ID, memberInfo)); + when(idGenerator.nextId()).thenReturn(100L, 101L); + when(tokenEncryptor.encrypt(any())).thenReturn(ENCRYPTED_TOKEN); // when service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); @@ -91,6 +116,24 @@ void should_activateAttendance_when_leaderStartsAttendance() { // then assertThat(schedule.isAttendanceActive()).isTrue(); verify(eventPublisher).publishEvent(any(Object.class)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(attendanceRepository).saveAll(captor.capture()); + + List savedAttendances = captor.getValue(); + assertThat(savedAttendances).hasSize(2); + + Attendance ownerAttendance = savedAttendances.stream() + .filter(a -> a.getMemberInfo().getMemberId().equals(MEMBER_ID)) + .findFirst() + .orElseThrow(); + assertThat(ownerAttendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + + Attendance memberAttendance = savedAttendances.stream() + .filter(a -> a.getMemberInfo().getMemberId().equals(OTHER_MEMBER_ID)) + .findFirst() + .orElseThrow(); + assertThat(memberAttendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.UNDECIDED); } @Test @@ -181,41 +224,36 @@ void should_throwException_when_notStudyLeader() { class CheckAttendance { @Test - @DisplayName("스터디 멤버가 출석체크를 하면 출석 기록이 저장된다") - void should_saveAttendance_when_memberChecksIn() { + @DisplayName("스터디 멤버가 토큰으로 출석체크를 하면 출석 상태가 PRESENT로 변경된다") + void should_markAttendancePresent_when_memberChecksInWithToken() { // given Schedule schedule = ongoingScheduleWithAttendance(); - MemberInfoResponse memberInfo = new MemberInfoResponse(MEMBER_NAME, MEMBER_PROFILE_URL); + Attendance existingAttendance = attendance(); + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); - when(attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) - .thenReturn(false); - when(getMemberInfoPort.getMemberInfo(any())).thenReturn(Map.of(MEMBER_ID, memberInfo)); - when(idGenerator.nextId()).thenReturn(200L); + when(attendanceRepository.findByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(Optional.of(existingAttendance)); // when - service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID); // then - ArgumentCaptor captor = ArgumentCaptor.forClass(Attendance.class); - verify(attendanceRepository).save(captor.capture()); - - Attendance saved = captor.getValue(); - assertThat(saved.getScheduleId()).isEqualTo(SCHEDULE_ID); - assertThat(saved.getMemberInfo().getMemberId()).isEqualTo(MEMBER_ID); - assertThat(saved.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + assertThat(existingAttendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + assertThat(existingAttendance.getAttendedAt()).isNotNull(); } @Test @DisplayName("스터디 멤버가 아니면 출석체크를 할 수 없다") void should_throwException_when_notStudyMember() { // given + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); // when & then - assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, OTHER_MEMBER_ID)) .isInstanceOf(GeneralException.class) .extracting(e -> ((GeneralException) e).getStatus()) .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); @@ -227,32 +265,48 @@ void should_throwException_when_attendanceNotStarted() { // given Schedule schedule = ongoingSchedule(); + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); // when & then - assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) .isInstanceOf(GeneralException.class) .extracting(e -> ((GeneralException) e).getStatus()) .isEqualTo(ErrorStatus._ATTENDANCE_NOT_STARTED); } @Test - @DisplayName("이미 출석체크를 했으면 중복 출석할 수 없다") - void should_throwException_when_alreadyCheckedIn() { + @DisplayName("유효하지 않은 토큰이면 예외를 던진다") + void should_throwException_when_invalidToken() { + // given + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)) + .thenThrow(new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN)); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._INVALID_ATTENDANCE_TOKEN); + } + + @Test + @DisplayName("출석 기록이 없으면 예외를 던진다") + void should_throwException_when_attendanceRecordNotFound() { // given Schedule schedule = ongoingScheduleWithAttendance(); + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); - when(attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) - .thenReturn(true); + when(attendanceRepository.findByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) .isInstanceOf(GeneralException.class) .extracting(e -> ((GeneralException) e).getStatus()) - .isEqualTo(ErrorStatus._ATTENDANCE_ALREADY_CHECKED); + .isEqualTo(ErrorStatus._STUDY_MEMBER_NOT_FOUND); } @Test @@ -260,17 +314,23 @@ void should_throwException_when_alreadyCheckedIn() { void should_throwException_when_scheduleTimeExpired() { // given Schedule schedule = pastSchedule(); - // 과거 일정이지만 강제로 출석체크 활성화 상태로 설정 (테스트용) ReflectionTestUtils.setField(schedule, "attendanceActive", true); + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); // when & then - assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) .isInstanceOf(GeneralException.class) .extracting(e -> ((GeneralException) e).getStatus()) .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); } } + + private StudyMember createApprovedMember(long id, long studyId, long memberId) { + StudyMember member = StudyMember.apply(id, studyId, memberId, "테스트"); + ReflectionTestUtils.setField(member, "studyMemberStatus", StudyMemberStatus.APPROVED); + return member; + } } diff --git a/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java index 043ad95..29ab87f 100644 --- a/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java +++ b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java @@ -5,6 +5,8 @@ import static kr.spot.schedule.common.AttendanceFixture.QR_CODE_URL; import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.attendance; +import static kr.spot.schedule.common.AttendanceFixture.memberInfo; import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; import static org.assertj.core.api.Assertions.assertThat; @@ -13,11 +15,16 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.when; +import java.util.List; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; +import kr.spot.schedule.domain.Attendance; import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.enums.AttendanceStatus; +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.study.application.validator.StudyAccessValidator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -34,13 +41,16 @@ class GetAttendanceServiceTest { @Mock ScheduleRepository scheduleRepository; @Mock + AttendanceRepository attendanceRepository; + @Mock StudyAccessValidator studyAccessValidator; GetAttendanceService service; @BeforeEach void setUp() { - service = new GetAttendanceService(scheduleRepository, studyAccessValidator); + service = new GetAttendanceService(scheduleRepository, attendanceRepository, + studyAccessValidator); } @Nested @@ -114,4 +124,59 @@ void should_throwException_when_scheduleNotFound() { .isEqualTo(ErrorStatus._SCHEDULE_NOT_FOUND); } } + + @Nested + @DisplayName("출석 목록 조회") + class GetAttendanceList { + + @Test + @DisplayName("일정의 출석 목록을 조회한다") + void should_returnAttendanceList_when_requested() { + // given + Attendance attendance1 = attendance(); + Attendance attendance2 = Attendance.createPending(201L, SCHEDULE_ID, memberInfo(OTHER_MEMBER_ID)); + attendance2.markAttendance(AttendanceStatus.PRESENT); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(attendanceRepository.findAllByScheduleId(SCHEDULE_ID)) + .thenReturn(List.of(attendance1, attendance2)); + + // when + GetAttendanceListResponse response = service.getAttendanceList(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.attendances()).hasSize(2); + } + + @Test + @DisplayName("출석 기록이 없으면 빈 목록을 반환한다") + void should_returnEmptyList_when_noAttendanceRecords() { + // given + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(attendanceRepository.findAllByScheduleId(SCHEDULE_ID)).thenReturn(List.of()); + + // when + GetAttendanceListResponse response = service.getAttendanceList(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.attendances()).isEmpty(); + } + + @Test + @DisplayName("스터디 멤버가 아니면 출석 목록을 조회할 수 없다") + void should_throwException_when_notStudyMember() { + // given + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy( + () -> service.getAttendanceList(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); + } + } } From 0dbbf8073d364214b5763115200ee1e3cb2e6a72 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:58:23 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[Feature]=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/AttendanceCommandController.java | 130 +++++++++++++++-- .../query/AttendanceQueryController.java | 131 +++++++++++++++++- 2 files changed, 243 insertions(+), 18 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java index 65177ca..7cd8fed 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java @@ -2,6 +2,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; @@ -16,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "스터디 일정 - 출석체크") +@Tag(name = "스터디 일정 - 출석체크", description = "스터디 일정 출석체크 관련 API") @RestController @RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") @RequiredArgsConstructor @@ -24,32 +28,136 @@ public class AttendanceCommandController { private final AttendanceCommandService attendanceCommandService; - @Operation(summary = "출석체크 시작", description = "스터디장이 출석체크를 시작합니다. QR 코드 이미지는 비동기로 생성됩니다.") + @Operation( + summary = "출석체크 시작", + description = """ + 스터디장이 출석체크를 시작합니다. + + **동작:** + - 모든 스터디 멤버(OWNER, APPROVED)에 대해 출석 기록 생성 + - 스터디장(OWNER)은 자동으로 출석(PRESENT) 처리 + - 일반 멤버는 미정(UNDECIDED) 상태로 생성 + - QR 코드 이미지는 비동기로 생성되며, `/qr` 엔드포인트에서 조회 가능 + + **권한:** 스터디장만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석체크 시작 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "일정 시간이 아님 (ATTENDANCE4000) / 이미 출석체크 진행 중 (SCHEDULE4000)", + content = @Content(examples = { + @ExampleObject(name = "일정 시간 외", value = """ + {"isSuccess": false, "code": "ATTENDANCE4000", "message": "일정 시간 외에는 출석체크를 진행할 수 없습니다."} + """), + @ExampleObject(name = "이미 진행 중", value = """ + {"isSuccess": false, "code": "SCHEDULE4000", "message": "이미 출석 QR 코드가 할당된 일정입니다."} + """) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디장이 아님 (STUDY403)", + content = @Content(examples = @ExampleObject(value = """ + {"isSuccess": false, "code": "STUDY403", "message": "스터디장만 접근 가능합니다."} + """)) + ) + }) @PostMapping public ResponseEntity> startAttendanceCheck( - @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, - @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId) { attendanceCommandService.startAttendance(studyId, scheduleId, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } - @Operation(summary = "출석체크 종료", description = "스터디장이 출석체크를 종료합니다.") + @Operation( + summary = "출석체크 종료", + description = """ + 스터디장이 출석체크를 종료합니다. + + **동작:** + - 출석체크 비활성화 + - QR 코드 이미지 URL 삭제 + + **권한:** 스터디장만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석체크 종료 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디장이 아님 (STUDY403)" + ) + }) @DeleteMapping public ResponseEntity> stopAttendanceCheck( - @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, - @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId) { attendanceCommandService.stopAttendance(studyId, scheduleId, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } - @Operation(summary = "출석체크 처리", description = "QR 코드를 통해 출석체크를 처리합니다.") + @Operation( + summary = "출석체크 처리 (QR 스캔)", + description = """ + QR 코드를 스캔하여 출석체크를 처리합니다. + + **동작:** + - 암호화된 토큰을 복호화하여 studyId, scheduleId 추출 + - 해당 멤버의 출석 상태를 PRESENT로 변경 + + **QR 코드 토큰:** + - QR 코드에는 암호화된 토큰 문자열만 담겨 있음 + - 앱에서 QR 스캔 후 해당 토큰을 이 API의 token 파라미터로 전달 + + **권한:** 스터디 멤버만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석 처리 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "유효하지 않은 토큰 (ATTENDANCE4003) / 출석체크 미시작 (ATTENDANCE4001) / 일정 시간 외 (ATTENDANCE4000)", + content = @Content(examples = { + @ExampleObject(name = "유효하지 않은 토큰", value = """ + {"isSuccess": false, "code": "ATTENDANCE4003", "message": "유효하지 않은 출석 토큰입니다."} + """), + @ExampleObject(name = "출석체크 미시작", value = """ + {"isSuccess": false, "code": "ATTENDANCE4001", "message": "출석체크가 시작되지 않았습니다."} + """) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디 멤버가 아님 (STUDY4032)" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "출석 기록 없음 (STUDYMEMBER404)" + ) + }) @PostMapping("/check") public ResponseEntity> checkAttendance( - @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, - @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, - @Parameter(description = "암호화된 출석 토큰", required = true) @RequestParam String token, + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @Parameter( + description = "QR 코드에서 읽은 암호화된 출석 토큰", + required = true, + example = "dK8x2mP9qL3nR7vT..." + ) @RequestParam String token, @CurrentMember @Parameter(hidden = true) Long memberId) { attendanceCommandService.checkAttendance(token, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java index 1620a3d..cfe6bf6 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java @@ -2,6 +2,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; @@ -16,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "스터디 일정 - 출석체크") +@Tag(name = "스터디 일정 - 출석체크", description = "스터디 일정 출석체크 관련 API") @RestController @RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") @RequiredArgsConstructor @@ -24,11 +28,67 @@ public class AttendanceQueryController { private final GetAttendanceService getAttendanceService; - @Operation(summary = "출석 목록 조회", description = "해당 일정의 출석 목록을 조회합니다.") + @Operation( + summary = "출석 목록 조회", + description = """ + 해당 일정의 출석 목록을 조회합니다. + + **출석 상태 (attendanceStatus):** + - `PRESENT`: 출석 + - `ABSENT`: 결석 + - `UNDECIDED`: 미정 (아직 출석 안 함) + + **권한:** 스터디 멤버만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석 목록 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = GetAttendanceListResponse.class), + examples = @ExampleObject(value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendances": [ + { + "member": { + "memberId": 1, + "memberName": "홍길동", + "memberProfileImageUrl": "https://example.com/profile1.jpg" + }, + "attendanceStatus": "PRESENT", + "attendedAt": "2024-01-15T14:30:00" + }, + { + "member": { + "memberId": 2, + "memberName": "김철수", + "memberProfileImageUrl": "https://example.com/profile2.jpg" + }, + "attendanceStatus": "UNDECIDED", + "attendedAt": null + } + ], + "totalCount": 2 + } + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디 멤버가 아님 (STUDY4032)" + ) + }) @GetMapping public ResponseEntity> getAttendanceList( - @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, - @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId ) { GetAttendanceListResponse response = getAttendanceService.getAttendanceList(studyId, @@ -36,11 +96,68 @@ public ResponseEntity> getAttendanceList( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } - @Operation(summary = "출석체크 QR 코드 조회", description = "출석체크용 QR 코드 이미지 URL을 조회합니다.") + @Operation( + summary = "출석체크 QR 코드 조회", + description = """ + 출석체크용 QR 코드 이미지 URL을 조회합니다. + + **응답 필드:** + - `attendanceActive`: 출석체크 진행 중 여부 + - `qrCodeImageUrl`: QR 코드 이미지 URL (출석체크 미시작 시 null) + + **QR 코드 내용:** + - QR 코드에는 암호화된 토큰 문자열만 담겨 있음 + - 멤버가 QR 스캔 후 토큰을 `/check` API에 전달하여 출석 처리 + + **권한:** 스터디 멤버만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "QR 코드 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = GetAttendanceInfoResponse.class), + examples = { + @ExampleObject(name = "출석체크 진행 중", value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendanceActive": true, + "qrCodeImageUrl": "https://s3.amazonaws.com/.../qr-code.png" + } + } + """), + @ExampleObject(name = "출석체크 미시작", value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendanceActive": false, + "qrCodeImageUrl": null + } + } + """) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디 멤버가 아님 (STUDY4032)" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "일정을 찾을 수 없음 (SCHEDULE404)" + ) + }) @GetMapping("/qr") public ResponseEntity> getAttendanceQrCode( - @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, - @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId ) { GetAttendanceInfoResponse response = getAttendanceService.getAttendanceQrCode(studyId, From 8cfe8283c831f41048063437e85ce80a180ac34b Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 23:05:52 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[Feature]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/java/kr/spot/schedule/domain/AttendanceTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java index 2fdaef1..d825835 100644 --- a/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java +++ b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java @@ -5,6 +5,7 @@ import static kr.spot.schedule.common.AttendanceFixture.memberInfo; import static org.assertj.core.api.Assertions.assertThat; +import java.time.LocalDateTime; import kr.spot.schedule.domain.enums.AttendanceStatus; import kr.spot.schedule.domain.vo.MemberInfo; import org.junit.jupiter.api.DisplayName; @@ -31,7 +32,6 @@ void should_createWithUndecidedStatus() { assertThat(attendance.getScheduleId()).isEqualTo(SCHEDULE_ID); assertThat(attendance.getMemberInfo()).isEqualTo(member); assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.UNDECIDED); - assertThat(attendance.getAttendedAt()).isNotNull(); } } @@ -70,13 +70,13 @@ void should_markAsAbsent() { void should_updateAttendedAt_when_markAttendance() { // given Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); - var originalTime = attendance.getAttendedAt(); + LocalDateTime testPoint = LocalDateTime.now(); // when attendance.markAttendance(AttendanceStatus.PRESENT); // then - assertThat(attendance.getAttendedAt()).isAfterOrEqualTo(originalTime); + assertThat(attendance.getAttendedAt()).isAfterOrEqualTo(testPoint); } } }