From f88841e54c60709f109caa33de5b45c2d492cfdd Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 13 Oct 2026 08:03:23 +0900 Subject: [PATCH 01/14] =?UTF-8?q?perf:=20=EC=B6=9C=EC=84=9D=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=ED=95=99=EC=83=9D=EC=9D=98=20=EC=96=BC=EA=B5=B4=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=EB=A7=8C=20=EC=9D=BD=EB=8A=94=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FaceTemplateRepository.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java b/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java index 9e239834..20fcd526 100644 --- a/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java +++ b/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java @@ -29,4 +29,29 @@ public interface FaceTemplateRepository extends JpaRepository findAllByStudent_IdIn(Collection studentIds); void deleteByStudent_Id(Long studentId); + + /** + * 출석 대상 학생의 템플릿만 학생 id 순으로 읽는다. 얼굴 인식 세션을 만들 때 쓴다. + * 조건은 {@link com.checkup.checkup.domain.member.entity.Student#isAttendanceEligible()}(동의 2종, 호실)과 + * DataGSM 학생 id가 있는 것이다. 출석 대상 규칙이 바뀌면 이 쿼리와 {@link #findAllEligibleByStudentIdIn}도 같이 바꾼다. + */ + @Query(""" + SELECT template FROM FaceTemplate template JOIN FETCH template.student student + WHERE student.privacyAgreedAt IS NOT NULL AND student.faceAgreedAt IS NOT NULL + AND student.dormitoryRoom IS NOT NULL AND student.datagsmStudentId > 0 + ORDER BY student.id + """) + List findAllEligible(); + + /** + * 주어진 학생 중 출석 대상인 학생의 템플릿만 읽는다. AI 세션을 다시 만들 때 쓴다. + * 조건은 {@link #findAllEligible()}와 같다. + */ + @Query(""" + SELECT template FROM FaceTemplate template JOIN FETCH template.student student + WHERE student.id IN :studentIds + AND student.privacyAgreedAt IS NOT NULL AND student.faceAgreedAt IS NOT NULL + AND student.dormitoryRoom IS NOT NULL AND student.datagsmStudentId > 0 + """) + List findAllEligibleByStudentIdIn(@Param("studentIds") Collection studentIds); } From e8767f11cf0cb2d9c5dc3fa6fb10f5f460371aa5 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 13 Oct 2026 08:03:23 +0900 Subject: [PATCH 02/14] =?UTF-8?q?perf:=20=EC=96=BC=EA=B5=B4=20=EC=9D=B8?= =?UTF-8?q?=EC=8B=9D=20=ED=9B=84=EB=B3=B4=EB=A5=BC=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B6=9C=EC=84=9D=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B1=B0=EB=A5=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/face/service/FaceRecognitionService.java | 13 ++----------- .../face/service/FaceRecognitionServiceTest.java | 12 ++++++------ 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java b/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java index 628b0e26..1bc27f97 100644 --- a/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java +++ b/src/main/java/com/checkup/checkup/domain/face/service/FaceRecognitionService.java @@ -433,9 +433,7 @@ private static Long canonicalStudentId(String value) { } private List eligibleTemplates() { - return faceTemplateRepository.findAllByOrderByStudent_IdAsc().stream() - .filter(template -> eligibleStudent(template.getStudent())) - .toList(); + return faceTemplateRepository.findAllEligible(); } private AiFaceSessionRequest sessionRequest(List templates) { @@ -451,11 +449,6 @@ private AiFaceSessionRequest sessionRequest(List templates) { return new AiFaceSessionRequest(model, candidates); } - private static boolean eligibleStudent(Student student) { - return student.isAttendanceEligible() - && student.getDatagsmStudentId() != null && student.getDatagsmStudentId() > 0; - } - private AiFaceSessionRequest candidateRequest(Set studentIds) { return candidateRequestSnapshot(studentIds, null).request(); } @@ -475,9 +468,7 @@ private Map candidateTemplateRevisions(Set studentIds) { } private List candidateTemplates(Set studentIds) { - List templates = faceTemplateRepository.findAllByStudent_IdIn(studentIds).stream() - .filter(template -> eligibleStudent(template.getStudent())) - .toList(); + List templates = faceTemplateRepository.findAllEligibleByStudentIdIn(studentIds); if (templates.size() != studentIds.size() || templates.size() > properties.maxCandidates() || templates.isEmpty()) { throw new CustomException(ErrorCode.FACE_SESSION_NOT_FOUND); } diff --git a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java index 800182d5..eac99f89 100644 --- a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java +++ b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java @@ -87,7 +87,7 @@ void refreshesActiveSessionWithCurrentCandidates() { FaceTemplate otherTemplate = template(OTHER_STUDENT_DB_ID, OTHER_DATAGSM_STUDENT_ID, vectorJson(1)); given(sessionStore.findWithStudent(STUDENT_DB_ID)).willReturn(List.of(session)); given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true); - given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID))) + given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID))) .willReturn(List.of(refreshedTemplate, otherTemplate)); service.refreshStudentTemplate(STUDENT_DB_ID); @@ -109,7 +109,7 @@ void retriesWhenAnotherCandidateChangesDuringRefresh() { FaceTemplate otherAfter = versionedTemplate(OTHER_STUDENT_DB_ID, OTHER_DATAGSM_STUDENT_ID, 1L, vectorJson(2)); given(sessionStore.findWithStudent(STUDENT_DB_ID)).willReturn(List.of(session)); given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true); - given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID))) + given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID))) .willReturn(List.of(target, otherBefore), List.of(target, otherAfter)); service.refreshStudentTemplate(STUDENT_DB_ID); @@ -128,7 +128,7 @@ void closesSessionWhenTemplateRefreshFails() { FaceTemplate refreshedTemplate = template(STUDENT_DB_ID, DATAGSM_STUDENT_ID, vectorJson(0)); given(sessionStore.findWithStudent(STUDENT_DB_ID)).willReturn(List.of(session)); given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true); - given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID))) + given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID))) .willReturn(List.of(refreshedTemplate)); willThrow(new IllegalStateException("AI unavailable")) .given(aiFaceClient).createSession(eq(SESSION_ID), any()); @@ -256,7 +256,7 @@ void recoveryExtendsFrameLock() throws Exception { given(template.getDimension()).willReturn(256); given(template.getNormalization()).willReturn("l2"); given(template.getVectorsJson()).willReturn(toVectorsJson()); - given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID))).willReturn(List.of(template)); + given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID))).willReturn(List.of(template)); given(aiFaceClient.recognize(eq(SESSION_ID), eq("frame-1"), any(), any())) .willAnswer(invocation -> { clock.advance(Duration.ofSeconds(80)); @@ -291,7 +291,7 @@ void ownershipIsSavedBeforeAiSessionForCleanupRetry() throws Exception { given(template.getDimension()).willReturn(256); given(template.getNormalization()).willReturn("l2"); given(template.getVectorsJson()).willReturn(toVectorsJson()); - given(templateRepository.findAllByOrderByStudent_IdAsc()).willReturn(List.of(template)); + given(templateRepository.findAllEligible()).willReturn(List.of(template)); FaceSessionView pending = session(Set.of(STUDENT_DB_ID)); given(sessionStore.findOwnedIfPresent(any(), eq(ADMIN_ID))).willReturn(java.util.Optional.of(pending)); org.mockito.BDDMockito.willThrow(new AiFaceException(503, "session_create", "unavailable")) @@ -325,7 +325,7 @@ void tooManyCandidatesAreRejectedByConfiguredMaximum() { tools.jackson.databind.json.JsonMapper.builder().build(), clock); FaceTemplate first = eligibleTemplate(1L); FaceTemplate second = eligibleTemplate(2L); - given(templateRepository.findAllByOrderByStudent_IdAsc()).willReturn(List.of(first, second)); + given(templateRepository.findAllEligible()).willReturn(List.of(first, second)); org.assertj.core.api.Assertions.assertThatThrownBy(() -> limitedService.create(ADMIN_ID, com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY)) From 5847e97d12102940e1ca68e4f21eb4e08653d117 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 13 Oct 2026 08:03:23 +0900 Subject: [PATCH 03/14] =?UTF-8?q?refactor:=20=EC=93=B0=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EC=96=BC=EA=B5=B4=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/face/repository/FaceTemplateRepository.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java b/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java index 20fcd526..82c8fb8e 100644 --- a/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java +++ b/src/main/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepository.java @@ -2,7 +2,6 @@ import com.checkup.checkup.domain.face.entity.FaceTemplate; import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -22,12 +21,6 @@ public interface FaceTemplateRepository extends JpaRepository findForUpdateByStudentId(@Param("studentId") Long studentId); - @EntityGraph(attributePaths = "student") - List findAllByOrderByStudent_IdAsc(); - - @EntityGraph(attributePaths = "student") - List findAllByStudent_IdIn(Collection studentIds); - void deleteByStudent_Id(Long studentId); /** From 6359ecca4ad8df8504f47a3e6385ef1a47f9f110 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 13 Oct 2026 08:03:25 +0900 Subject: [PATCH 04/14] =?UTF-8?q?test:=20=EC=B6=9C=EC=84=9D=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EC=96=BC=EA=B5=B4=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FaceTemplateRepositoryTest.java | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/test/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepositoryTest.java diff --git a/src/test/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepositoryTest.java b/src/test/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepositoryTest.java new file mode 100644 index 00000000..2759aada --- /dev/null +++ b/src/test/java/com/checkup/checkup/domain/face/repository/FaceTemplateRepositoryTest.java @@ -0,0 +1,114 @@ +package com.checkup.checkup.domain.face.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.checkup.checkup.domain.face.ai.AiFaceModel; +import com.checkup.checkup.domain.face.entity.FaceTemplate; +import com.checkup.checkup.domain.member.entity.Student; +import com.checkup.checkup.domain.member.repository.StudentRepository; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * 실제 PostgreSQL에서 얼굴 인식 세션 후보 쿼리가 출석 대상 학생의 템플릿만 읽는지 검증한다(#236). + * 조건은 {@link Student#isAttendanceEligible()}와 DataGSM 학생 id가 있는 것이다. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class FaceTemplateRepositoryTest { + + private static final Instant T0 = Instant.parse("2026-10-13T03:00:00Z"); + private static final AiFaceModel MODEL = new AiFaceModel("model-a", "v1", 256, "l2"); + + @Autowired + private FaceTemplateRepository repository; + + @Autowired + private StudentRepository studentRepository; + + private Long eligibleId; + private Long otherEligibleId; + private Long noConsentId; + private Long noFaceConsentId; + private Long noPrivacyConsentId; + private Long noRoomId; + private Long noDatagsmIdId; + + @BeforeEach + void setUp() { + eligibleId = saveWithTemplate(eligible(94_001L, 301)); + otherEligibleId = saveWithTemplate(eligible(94_002L, 302)); + + noConsentId = saveWithTemplate(Student.createWithoutMember(94_003L, "동의 안 함", 2, 1, 3, 2103, 303)); + + Student noFaceConsent = eligible(94_004L, 304); + ReflectionTestUtils.setField(noFaceConsent, "faceAgreedAt", null); + noFaceConsentId = saveWithTemplate(noFaceConsent); + + Student noPrivacyConsent = eligible(94_005L, 305); + ReflectionTestUtils.setField(noPrivacyConsent, "privacyAgreedAt", null); + noPrivacyConsentId = saveWithTemplate(noPrivacyConsent); + + Student noRoom = eligible(94_006L, 306); + noRoom.leaveDormitory(); + noRoomId = saveWithTemplate(noRoom); + + noDatagsmIdId = saveWithTemplate(eligible(null, 307)); + } + + @Test + @DisplayName("동의 2종·호실·DataGSM 학생 id가 모두 있는 학생의 템플릿만 학생 id 순으로 읽는다") + void findAllEligibleReturnsOnlyAttendanceEligibleStudents() { + List templates = repository.findAllEligible(); + + assertThat(templates).extracting(template -> template.getStudent().getId()) + .containsExactly(eligibleId, otherEligibleId); + } + + @Test + @DisplayName("주어진 학생 중에서도 출석 대상이 아닌 학생의 템플릿은 빠진다") + void findAllEligibleByStudentIdInSkipsIneligibleStudents() { + List requested = List.of(eligibleId, noConsentId, noFaceConsentId, noPrivacyConsentId, + noRoomId, noDatagsmIdId); + + List templates = repository.findAllEligibleByStudentIdIn(requested); + + assertThat(templates).extracting(template -> template.getStudent().getId()) + .containsExactly(eligibleId); + } + + @Test + @DisplayName("쿼리 결과는 Student.isAttendanceEligible()과 같은 판단이다") + void queryMatchesEntityRule() { + List templates = repository.findAllEligible(); + + assertThat(templates).allSatisfy(template -> { + assertThat(template.getStudent().isAttendanceEligible()).isTrue(); + assertThat(template.getStudent().getDatagsmStudentId()).isPositive(); + }); + assertThat(repository.findAll()).filteredOn(template -> template.getStudent().isAttendanceEligible() + && template.getStudent().getDatagsmStudentId() != null + && template.getStudent().getDatagsmStudentId() > 0) + .hasSameSizeAs(templates); + } + + private Student eligible(Long datagsmStudentId, int room) { + Student student = Student.createWithoutMember(datagsmStudentId, "학생" + room, 2, 1, room % 100, 2100 + room % 100, + room); + student.agree(false, T0, "v1"); + return student; + } + + private Long saveWithTemplate(Student student) { + Student saved = studentRepository.save(student); + repository.save(FaceTemplate.create(saved, MODEL, "[[1.0]]")); + return saved.getId(); + } +} From 27fff5ce0b8ce2a30f2137afc99ec54575b42883 Mon Sep 17 00:00:00 2001 From: minwoo-3 Date: Tue, 13 Oct 2026 08:03:25 +0900 Subject: [PATCH 05/14] =?UTF-8?q?test:=20=EC=B6=9C=EC=84=9D=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=ED=85=9C=ED=94=8C=EB=A6=BF=EC=9D=B4=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20=EC=84=B8=EC=85=98=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EA=B1=B0=EB=B6=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../face/service/FaceRecognitionServiceTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java index eac99f89..6f870941 100644 --- a/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java +++ b/src/test/java/com/checkup/checkup/domain/face/service/FaceRecognitionServiceTest.java @@ -337,6 +337,21 @@ void tooManyCandidatesAreRejectedByConfiguredMaximum() { verify(aiFaceClient, never()).createSession(any(), any()); } + @Test + @DisplayName("출석 대상 템플릿이 없으면 세션을 만들지 않고 409 FACE_NO_ENROLLED_STUDENTS다") + void noEligibleTemplateIsRejected() { + given(templateRepository.findAllEligible()).willReturn(List.of()); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.create(ADMIN_ID, + com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY)) + .isInstanceOfSatisfying(com.checkup.checkup.global.exception.CustomException.class, + error -> assertThat(error.getErrorCode()).isEqualTo( + com.checkup.checkup.global.exception.ErrorCode.FACE_NO_ENROLLED_STUDENTS)); + + verify(sessionStore, never()).create(any(), any(), any(), any(), any(), any()); + verify(aiFaceClient, never()).createSession(any(), any()); + } + @Test @DisplayName("후보 상한을 따로 정하지 않으면 기본 200명이다") void defaultMaximumIs200() { From d295486805ce8590ed5961ad3b10d09182ef0f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:24:05 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EB=8B=B9=EC=9D=BC=20=EB=B4=89?= =?UTF-8?q?=EC=82=AC=20=EB=AF=B8=EC=9D=B4=ED=96=89=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=99=80=20=EC=A7=80=EC=A0=95=EC=97=90=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=ED=95=9C=20=EC=A1=B0=EC=A0=95=20=EA=B8=B0=EB=A1=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/volunteer/entity/DutyStatus.java | 7 ++++++- .../volunteer/entity/VolunteerAdjustment.java | 5 +++++ .../volunteer/entity/VolunteerAdjustmentKind.java | 4 +++- .../migration/V20__add_volunteer_duty_missed.sql | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/migration/V20__add_volunteer_duty_missed.sql diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/entity/DutyStatus.java b/src/main/java/com/checkup/checkup/domain/volunteer/entity/DutyStatus.java index c44f820c..3e5bc70c 100644 --- a/src/main/java/com/checkup/checkup/domain/volunteer/entity/DutyStatus.java +++ b/src/main/java/com/checkup/checkup/domain/volunteer/entity/DutyStatus.java @@ -7,5 +7,10 @@ public enum DutyStatus { /** 사감이 오늘 봉사자로 지정했다. 아직 봉사 완료를 확인하지 않았다. */ ASSIGNED, /** 자치위원이 봉사 완료를 확인했다. 이때 봉사 횟수를 1 줄였다. */ - COMPLETED + COMPLETED, + /** + * 봉사를 하지 않았다(#250). 관리자가 미이행으로 처리했거나, 운영일이 지나도록 완료·미이행 처리가 없던 지정이다. + * 뒤의 경우는 저장값이 {@code ASSIGNED}이고 조회할 때만 미이행으로 본다. + */ + MISSED } diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/entity/VolunteerAdjustment.java b/src/main/java/com/checkup/checkup/domain/volunteer/entity/VolunteerAdjustment.java index 075ec6b1..cc6e1afd 100644 --- a/src/main/java/com/checkup/checkup/domain/volunteer/entity/VolunteerAdjustment.java +++ b/src/main/java/com/checkup/checkup/domain/volunteer/entity/VolunteerAdjustment.java @@ -53,6 +53,11 @@ public class VolunteerAdjustment { @Column(length = 100) private String reason; + /** 미이행 처리({@code DUTY_MISSED})로 생긴 기록의 당일 봉사자 지정. 다른 종류는 {@code null}이다. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "duty_id") + private VolunteerDuty duty; + /** 웹이 보낸 재시도 방지 키. 보내지 않았으면 {@code null}이다. */ @Column(length = 100) private String requestKey; diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/entity/VolunteerAdjustmentKind.java b/src/main/java/com/checkup/checkup/domain/volunteer/entity/VolunteerAdjustmentKind.java index 3d2135c4..6a09acae 100644 --- a/src/main/java/com/checkup/checkup/domain/volunteer/entity/VolunteerAdjustmentKind.java +++ b/src/main/java/com/checkup/checkup/domain/volunteer/entity/VolunteerAdjustmentKind.java @@ -5,8 +5,10 @@ * * - {@code ADMIN}: 관리자의 {@code +}/{@code −}·여러 회 조정. * - {@code DUTY_COMPLETION}: 자치위원이 당일 봉사 완료를 확인해 생긴 차감(-1). + * - {@code DUTY_MISSED}: 당일 봉사 미이행으로 처리하며 바꾼 횟수(#250). 그 지정에 연결된다. */ public enum VolunteerAdjustmentKind { ADMIN, - DUTY_COMPLETION + DUTY_COMPLETION, + DUTY_MISSED } diff --git a/src/main/resources/db/migration/V20__add_volunteer_duty_missed.sql b/src/main/resources/db/migration/V20__add_volunteer_duty_missed.sql new file mode 100644 index 00000000..c7498b4a --- /dev/null +++ b/src/main/resources/db/migration/V20__add_volunteer_duty_missed.sql @@ -0,0 +1,14 @@ +-- #250 당일 봉사 미이행. 지정을 지우지 않고 MISSED로 남기고, 미이행으로 추가한 횟수는 그 지정에 연결한 조정 기록으로 남긴다. +-- 운영일이 지났는데 ASSIGNED인 지정은 저장값을 바꾸지 않고 조회할 때 미이행(횟수 미처리)으로 본다. +ALTER TABLE volunteer_duty DROP CONSTRAINT ck_volunteer_duty_status; +ALTER TABLE volunteer_duty + ADD CONSTRAINT ck_volunteer_duty_status CHECK (status IN ('ASSIGNED', 'COMPLETED', 'MISSED')); + +-- duty_id는 미이행 처리(DUTY_MISSED)로 생긴 조정 기록의 지정이다. 지정 하나에 미이행 조정은 하나뿐이다. +ALTER TABLE volunteer_adjustment + ADD COLUMN duty_id BIGINT REFERENCES volunteer_duty (id) ON DELETE SET NULL; +CREATE UNIQUE INDEX uk_volunteer_adjustment_missed_duty ON volunteer_adjustment (duty_id) WHERE kind = 'DUTY_MISSED'; + +ALTER TABLE volunteer_adjustment DROP CONSTRAINT ck_volunteer_adjustment_kind; +ALTER TABLE volunteer_adjustment + ADD CONSTRAINT ck_volunteer_adjustment_kind CHECK (kind IN ('ADMIN', 'DUTY_COMPLETION', 'DUTY_MISSED')); From 38b6d27dc0a06a5cb564aafd4a2beaad1295bbc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:26:05 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=EB=AF=B8=EC=9D=B4=ED=96=89=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EC=99=80=20=EB=82=A0=EC=A7=9C=EB=B3=84=C2=B7?= =?UTF-8?q?=EC=9B=94=EB=B3=84=20=EB=8B=B9=EC=9D=BC=20=EB=B4=89=EC=82=AC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VolunteerAdjustmentRepository.java | 35 +++++++++++ .../repository/VolunteerDutyRepository.java | 57 +++++++++++++++++ .../VolunteerAdjustmentRepositoryTest.java | 45 +++++++++++++- .../VolunteerDutyRepositoryTest.java | 62 ++++++++++++++++++- 4 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/repository/VolunteerAdjustmentRepository.java b/src/main/java/com/checkup/checkup/domain/volunteer/repository/VolunteerAdjustmentRepository.java index 4789bc13..0efb28dd 100644 --- a/src/main/java/com/checkup/checkup/domain/volunteer/repository/VolunteerAdjustmentRepository.java +++ b/src/main/java/com/checkup/checkup/domain/volunteer/repository/VolunteerAdjustmentRepository.java @@ -1,6 +1,7 @@ package com.checkup.checkup.domain.volunteer.repository; import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustment; +import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustmentKind; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -8,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.time.Instant; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -40,6 +42,39 @@ int insertIfAbsent( @Param("createdAt") Instant createdAt ); + /** + * 당일 봉사 미이행 처리로 바꾼 횟수를 그 지정에 연결해 남긴다(#250). 같은 {@code requestKey}가 이미 있으면 예외 없이 무시한다. + * + * @param dutyId 미이행으로 처리한 지정 + * @param delta 실제로 바뀐 횟수 + * @param requestedDelta 관리자가 요청한 횟수 + * @param reason 사유. 없으면 {@code null} + * @return 새로 저장했으면 1, 같은 키가 있어 무시했으면 0 + */ + @Modifying + @Query(value = """ + INSERT INTO volunteer_adjustment + (student_id, duty_id, delta, requested_delta, reason, kind, request_key, created_at) + VALUES (:studentId, :dutyId, :delta, :requestedDelta, CAST(:reason AS VARCHAR), 'DUTY_MISSED', + :requestKey, :createdAt) + ON CONFLICT (request_key) DO NOTHING + """, nativeQuery = true) + int insertMissed( + @Param("studentId") Long studentId, + @Param("dutyId") Long dutyId, + @Param("delta") int delta, + @Param("requestedDelta") int requestedDelta, + @Param("reason") String reason, + @Param("requestKey") String requestKey, + @Param("createdAt") Instant createdAt + ); + + /** 지정들에 연결된 한 종류의 조정 기록. 날짜별 당일 봉사자 기록에서 미이행으로 추가한 횟수·사유를 읽는다. */ + List findAllByDuty_IdInAndKind(Collection dutyIds, VolunteerAdjustmentKind kind); + + /** 지정에 그 종류의 조정 기록이 있는지. 미이행 횟수를 이미 처리했는지 확인한다. */ + boolean existsByDuty_IdAndKind(Long dutyId, VolunteerAdjustmentKind kind); + /** * 학생 한 명의 조정 기록을 최신순으로 읽는다. 같은 시각이면 나중에 저장한 기록이 앞이다. 봉사 조정 이력(#140)에 쓴다. * diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/repository/VolunteerDutyRepository.java b/src/main/java/com/checkup/checkup/domain/volunteer/repository/VolunteerDutyRepository.java index 4afc97b0..d42026be 100644 --- a/src/main/java/com/checkup/checkup/domain/volunteer/repository/VolunteerDutyRepository.java +++ b/src/main/java/com/checkup/checkup/domain/volunteer/repository/VolunteerDutyRepository.java @@ -2,7 +2,9 @@ import com.checkup.checkup.domain.volunteer.entity.DutyStatus; import com.checkup.checkup.domain.volunteer.entity.VolunteerDuty; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -35,6 +37,41 @@ int assign( /** 학생의 그 운영일 지정. */ Optional findByStudentIdAndOperatingDay(Long studentId, LocalDate operatingDay); + /** + * 학생의 그 운영일 지정을 잠가 읽는다. 미이행 처리가 같은 지정에 겹쳐 오면 차례로 처리한다. + * 완료 처리와 같은 순서(지정 → 학생)로 잠가 서로 기다리다 멈추지 않는다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT d FROM VolunteerDuty d WHERE d.student.id = :studentId AND d.operatingDay = :operatingDay") + Optional lockByStudentIdAndOperatingDay( + @Param("studentId") Long studentId, + @Param("operatingDay") LocalDate operatingDay + ); + + /** 그 운영일의 모든 지정을 학생과 함께 읽는다. 날짜별 당일 봉사자 기록(#250)에 쓴다. */ + @Query("SELECT d FROM VolunteerDuty d JOIN FETCH d.student WHERE d.operatingDay = :operatingDay") + List findAllWithStudentByOperatingDay(@Param("operatingDay") LocalDate operatingDay); + + /** + * 기간 안에서 미이행이 있는 운영일과 그날의 미이행 인원을 날짜순으로 센다(#250). + * 미이행으로 처리한 지정과, {@code today} 전 운영일인데 아직 지정 상태인 지정을 함께 센다. + */ + @Query(""" + SELECT d.operatingDay AS operatingDay, COUNT(d) AS missedCount + FROM VolunteerDuty d + WHERE d.operatingDay BETWEEN :from AND :to + AND (d.status = com.checkup.checkup.domain.volunteer.entity.DutyStatus.MISSED + OR (d.status = com.checkup.checkup.domain.volunteer.entity.DutyStatus.ASSIGNED + AND d.operatingDay < :today)) + GROUP BY d.operatingDay + ORDER BY d.operatingDay + """) + List countMissedByOperatingDay( + @Param("from") LocalDate from, + @Param("to") LocalDate to, + @Param("today") LocalDate today + ); + /** * 학생의 봉사 지정 중 한 상태인 것을 최신 운영일부터 읽는다. 학생 본인 봉사 완료 내역(REQ-COM-003)에 쓴다. */ @@ -67,4 +104,24 @@ int assign( WHERE d.id = :id AND d.status = com.checkup.checkup.domain.volunteer.entity.DutyStatus.ASSIGNED """) int cancel(@Param("id") Long id); + + /** + * 지정을 미이행으로 바꾼다. 완료한 지정은 바꾸지 않는다. + * + * @return 미이행으로 바꿨거나 이미 미이행이면 1, 완료였으면 0 + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(""" + UPDATE VolunteerDuty d + SET d.status = com.checkup.checkup.domain.volunteer.entity.DutyStatus.MISSED + WHERE d.id = :id AND d.status <> com.checkup.checkup.domain.volunteer.entity.DutyStatus.COMPLETED + """) + int markMissed(@Param("id") Long id); + + /** 운영일별 미이행 인원 조회 결과. */ + interface MissedDay { + LocalDate getOperatingDay(); + + long getMissedCount(); + } } diff --git a/src/test/java/com/checkup/checkup/domain/volunteer/repository/VolunteerAdjustmentRepositoryTest.java b/src/test/java/com/checkup/checkup/domain/volunteer/repository/VolunteerAdjustmentRepositoryTest.java index a9c442be..3bfd4222 100644 --- a/src/test/java/com/checkup/checkup/domain/volunteer/repository/VolunteerAdjustmentRepositoryTest.java +++ b/src/test/java/com/checkup/checkup/domain/volunteer/repository/VolunteerAdjustmentRepositoryTest.java @@ -1,6 +1,7 @@ package com.checkup.checkup.domain.volunteer.repository; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.checkup.checkup.domain.member.entity.Member; import com.checkup.checkup.domain.member.entity.MemberRole; @@ -10,6 +11,7 @@ import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustment; import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustmentKind; import java.time.Instant; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -19,11 +21,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Limit; /** * 실제 PostgreSQL에서 봉사 조정 기록의 재시도 키 중복 무시, 마지막 조정 시각 조회, - * 봉사 횟수 증가와 0 하한 차감, 사유·요청 횟수 기록과 잠금 조회·여러 회 변경(#139), 학생별 최신순 이력 조회(#140), 조정 종류 기록(#176)을 검증한다. + * 봉사 횟수 증가와 0 하한 차감, 사유·요청 횟수 기록과 잠금 조회·여러 회 변경(#139), 학생별 최신순 이력 조회(#140), 조정 종류 기록(#176), + * 지정에 연결한 미이행 조정 기록(#250)을 검증한다. */ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -40,6 +44,9 @@ class VolunteerAdjustmentRepositoryTest { @Autowired private MemberRepository memberRepository; + @Autowired + private VolunteerDutyRepository dutyRepository; + private Long studentId; private Long otherStudentId; @@ -141,4 +148,40 @@ private Student saveStudent(int studentNumber) { Member member = memberRepository.save(Member.create(dataGsmId, "학생", MemberRole.STUDENT)); return studentRepository.saveAndFlush(Student.create(member, dataGsmId, "학생", 1, 1, 1, studentNumber, 301)); } + + @Test + @DisplayName("미이행 조정은 지정에 연결해 종류 DUTY_MISSED로 남고, 같은 키는 한 번만 기록한다") + void missedAdjustmentIsLinkedToDuty() { + Long dutyId = assignDuty(LocalDate.of(2026, 10, 1)); + Long otherDutyId = assignDuty(LocalDate.of(2026, 10, 2)); + + assertThat(adjustmentRepository.insertMissed(studentId, dutyId, 2, 2, "미이행", "key-m", T0)).isEqualTo(1); + assertThat(adjustmentRepository.insertMissed(studentId, otherDutyId, 2, 2, null, "key-m", T0)).isZero(); + + assertThat(adjustmentRepository.findAllByDuty_IdInAndKind(List.of(dutyId, otherDutyId), VolunteerAdjustmentKind.DUTY_MISSED)) + .singleElement() + .satisfies(adjustment -> { + assertThat(adjustment.getDuty().getId()).isEqualTo(dutyId); + assertThat(adjustment.getDelta()).isEqualTo((short) 2); + assertThat(adjustment.getReason()).isEqualTo("미이행"); + assertThat(adjustment.getKind()).isEqualTo(VolunteerAdjustmentKind.DUTY_MISSED); + }); + assertThat(adjustmentRepository.existsByDuty_IdAndKind(dutyId, VolunteerAdjustmentKind.DUTY_MISSED)).isTrue(); + assertThat(adjustmentRepository.existsByDuty_IdAndKind(otherDutyId, VolunteerAdjustmentKind.DUTY_MISSED)).isFalse(); + } + + @Test + @DisplayName("한 지정에는 미이행 조정을 두 번 남길 수 없다") + void missedAdjustmentIsUniquePerDuty() { + Long dutyId = assignDuty(LocalDate.of(2026, 10, 1)); + adjustmentRepository.insertMissed(studentId, dutyId, 1, 1, null, null, T0); + + assertThatThrownBy(() -> adjustmentRepository.insertMissed(studentId, dutyId, 1, 1, null, null, T0)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private Long assignDuty(LocalDate day) { + dutyRepository.assign(studentId, day, T0); + return dutyRepository.findByStudentIdAndOperatingDay(studentId, day).orElseThrow().getId(); + } } diff --git a/src/test/java/com/checkup/checkup/domain/volunteer/repository/VolunteerDutyRepositoryTest.java b/src/test/java/com/checkup/checkup/domain/volunteer/repository/VolunteerDutyRepositoryTest.java index 95bc283d..5f33aadd 100644 --- a/src/test/java/com/checkup/checkup/domain/volunteer/repository/VolunteerDutyRepositoryTest.java +++ b/src/test/java/com/checkup/checkup/domain/volunteer/repository/VolunteerDutyRepositoryTest.java @@ -11,6 +11,7 @@ import com.checkup.checkup.domain.volunteer.entity.VolunteerDuty; import java.time.Instant; import java.time.LocalDate; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,7 +21,8 @@ /** * 실제 PostgreSQL에서 당일 봉사자 지정이 학생·운영일마다 하나이고, - * 완료는 한 번만 반영되며 완료한 지정은 취소되지 않는지, 학생의 완료 내역을 최신 운영일부터 읽는지 검증한다. + * 완료는 한 번만 반영되며 완료한 지정은 취소되지 않는지, 학생의 완료 내역을 최신 운영일부터 읽는지, + * 미이행 처리와 날짜별 기록·월 단위 미이행 요약(#250)을 검증한다. */ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -105,4 +107,62 @@ void cancelAssigned() { assertThat(dutyRepository.cancel(id)).isEqualTo(1); assertThat(dutyRepository.findByStudentIdAndOperatingDay(studentId, DAY)).isEmpty(); } + + @Test + @DisplayName("미이행은 지정·미이행을 미이행으로 바꾸고 완료한 지정은 바꾸지 않는다") + void markMissedSkipsCompleted() { + Long assigned = assign(studentId, DAY); + Long completed = assign(studentId, DAY.plusDays(1)); + dutyRepository.complete(completed, T0.plusSeconds(60)); + + assertThat(dutyRepository.markMissed(assigned)).isEqualTo(1); + assertThat(dutyRepository.markMissed(assigned)).isEqualTo(1); + assertThat(dutyRepository.markMissed(completed)).isZero(); + + assertThat(dutyRepository.findById(assigned).orElseThrow().getStatus()).isEqualTo(DutyStatus.MISSED); + assertThat(dutyRepository.findById(completed).orElseThrow().getStatus()).isEqualTo(DutyStatus.COMPLETED); + assertThat(dutyRepository.cancel(assigned)).isZero(); + } + + @Test + @DisplayName("날짜별 기록은 그 운영일의 지정만 학생과 함께 읽고, 잠금 조회는 학생·운영일로 찾는다") + void findWithStudentByDay() { + assign(studentId, DAY); + assign(studentId, DAY.plusDays(1)); + + List duties = dutyRepository.findAllWithStudentByOperatingDay(DAY); + + assertThat(duties).singleElement() + .satisfies(duty -> assertThat(duty.getStudent().getName()).isEqualTo("학생")); + assertThat(dutyRepository.lockByStudentIdAndOperatingDay(studentId, DAY.plusDays(1))).isPresent(); + assertThat(dutyRepository.lockByStudentIdAndOperatingDay(studentId, DAY.plusDays(2))).isEmpty(); + } + + @Test + @DisplayName("월 요약은 미이행과 오늘 전 운영일의 지정을 날짜별로 세고, 완료·오늘 지정·기간 밖은 세지 않는다") + void countMissedByDay() { + Member other = memberRepository.save(Member.create(92_003L, "다른학생", MemberRole.STUDENT)); + Long otherId = studentRepository.saveAndFlush(Student.create(other, 92_003L, "다른학생", 1, 1, 3, 92_003, 302)).getId(); + LocalDate today = DAY.plusDays(3); + dutyRepository.markMissed(assign(studentId, DAY)); + assign(otherId, DAY); + dutyRepository.complete(assign(studentId, DAY.plusDays(1)), T0); + assign(studentId, DAY.plusDays(2)); + assign(studentId, today); + dutyRepository.markMissed(assign(otherId, today)); + assign(studentId, DAY.minusDays(1)); + + List days = + dutyRepository.countMissedByOperatingDay(DAY, DAY.plusDays(30), today); + + assertThat(days).extracting(VolunteerDutyRepository.MissedDay::getOperatingDay) + .containsExactly(DAY, DAY.plusDays(2), today); + assertThat(days).extracting(VolunteerDutyRepository.MissedDay::getMissedCount) + .containsExactly(2L, 1L, 1L); + } + + private Long assign(Long student, LocalDate day) { + dutyRepository.assign(student, day, T0); + return dutyRepository.findByStudentIdAndOperatingDay(student, day).orElseThrow().getId(); + } } From e67de21dc64fb0a039327cf15db0b3c74a7e3abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:27:56 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=EB=AF=B8=EC=9D=B4=ED=96=89=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=9A=94=EC=B2=AD=C2=B7=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=B3=84=20=EA=B8=B0=EB=A1=9D=20=EC=9D=91=EB=8B=B5=EA=B3=BC=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/VolunteerMissedRequest.java | 21 +++++++++ .../response/VolunteerDutyRecordResponse.java | 47 +++++++++++++++++++ .../checkup/global/exception/ErrorCode.java | 3 ++ 3 files changed, 71 insertions(+) create mode 100644 src/main/java/com/checkup/checkup/domain/volunteer/dto/request/VolunteerMissedRequest.java create mode 100644 src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerDutyRecordResponse.java diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/dto/request/VolunteerMissedRequest.java b/src/main/java/com/checkup/checkup/domain/volunteer/dto/request/VolunteerMissedRequest.java new file mode 100644 index 00000000..05345205 --- /dev/null +++ b/src/main/java/com/checkup/checkup/domain/volunteer/dto/request/VolunteerMissedRequest.java @@ -0,0 +1,21 @@ +package com.checkup.checkup.domain.volunteer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * 당일 봉사 미이행 처리 요청(#250). + * + * @param delta 미이행으로 바꿀 봉사 횟수(-99~99). 0이면 횟수는 그대로 두고 미이행만 기록한다. 차감은 남은 횟수까지만 한다 + * @param reason 사유(선택, 100자 이하). 학생 개인정보는 넣지 않는다 + */ +public record VolunteerMissedRequest( + @Schema(description = "바꿀 봉사 횟수(-99~99). 0이면 횟수는 그대로 두고 미이행만 기록한다. 차감은 남은 횟수까지만 한다", example = "1") + @NotNull @Min(-99) @Max(99) Integer delta, + @Schema(description = "사유(선택, 최대 100자). 학생 개인정보는 넣지 않는다", example = "당일 봉사 미이행") + @Size(max = 100) String reason +) { +} diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerDutyRecordResponse.java b/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerDutyRecordResponse.java new file mode 100644 index 00000000..08c828d6 --- /dev/null +++ b/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerDutyRecordResponse.java @@ -0,0 +1,47 @@ +package com.checkup.checkup.domain.volunteer.dto.response; + +import com.checkup.checkup.domain.member.entity.Student; +import com.checkup.checkup.domain.volunteer.entity.DutyStatus; +import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustment; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 날짜별 당일 봉사자 기록의 학생 한 명(#250). + * + * @param studentId DataGSM 학생 id + * @param studentNumber 학번 + * @param name 이름 + * @param dormitoryRoom 호실. 미배정이면 {@code null} + * @param status 그날 지정 상태. 운영일이 지났는데 완료·미이행 처리가 없던 지정은 {@code MISSED}다 + * @param adjusted 미이행으로 횟수를 처리했는지 + * @param addedCount 미이행으로 실제로 바꾼 횟수. 처리 전이면 {@code null}이고 음수일 수 있다 + * @param reason 미이행 처리 사유. 없으면 {@code null} + */ +public record VolunteerDutyRecordResponse( + @Schema(description = "DataGSM 학생 id", example = "3203") Long studentId, + @Schema(description = "화면 표시용 학번", example = "3203") int studentNumber, + @Schema(description = "이름", example = "류하준") String name, + @Schema(description = "호실. 미배정이면 null", example = "302") Integer dormitoryRoom, + @Schema(description = "그날 지정 상태. ASSIGNED(오늘 지정, 처리 전), COMPLETED(완료), MISSED(미이행). 운영일이 지났는데 처리가 없던 지정은 MISSED", example = "MISSED") DutyStatus status, + @Schema(description = "미이행으로 횟수를 처리했는지", example = "true") boolean adjusted, + @Schema(description = "미이행으로 실제로 바꾼 횟수. 처리 전이면 null, 음수일 수 있다", example = "1") Integer addedCount, + @Schema(description = "미이행 처리 사유. 없으면 null", example = "당일 봉사 미이행") String reason +) { + + /** + * @param student 학생 + * @param status 조회할 때 본 지정 상태 + * @param adjustment 그 지정의 미이행 조정 기록. 횟수를 처리하지 않았으면 {@code null} + */ + public static VolunteerDutyRecordResponse of(Student student, DutyStatus status, VolunteerAdjustment adjustment) { + return new VolunteerDutyRecordResponse( + student.getDatagsmStudentId(), + student.getStudentNumber(), + student.getName(), + student.getDormitoryRoom(), + status, + adjustment != null, + adjustment == null ? null : (int) adjustment.getDelta(), + adjustment == null ? null : adjustment.getReason()); + } +} diff --git a/src/main/java/com/checkup/checkup/global/exception/ErrorCode.java b/src/main/java/com/checkup/checkup/global/exception/ErrorCode.java index aafd856c..bd91bee2 100644 --- a/src/main/java/com/checkup/checkup/global/exception/ErrorCode.java +++ b/src/main/java/com/checkup/checkup/global/exception/ErrorCode.java @@ -48,6 +48,9 @@ public enum ErrorCode { ALREADY_ON_DUTY(HttpStatus.CONFLICT, "이미 오늘 봉사자로 지정된 학생입니다."), NOT_ON_DUTY(HttpStatus.NOT_FOUND, "오늘 봉사자로 지정되지 않은 학생입니다."), DUTY_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 봉사를 완료했습니다."), + DUTY_NOT_FOUND(HttpStatus.NOT_FOUND, "그날 봉사자로 지정되지 않은 학생입니다."), + DUTY_MISSED(HttpStatus.CONFLICT, "미이행으로 처리한 봉사입니다."), + DUTY_ALREADY_ADJUSTED(HttpStatus.CONFLICT, "이미 횟수를 처리한 미이행입니다. 학생 관리에서 횟수를 조정해 주세요."), // QR QR_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "QR 세션이 없거나 종료되었습니다."), From d801fe22d2105e2c759ac787cdae475ad872d9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:28:22 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=EB=8B=B9=EC=9D=BC=20=EB=B4=89?= =?UTF-8?q?=EC=82=AC=20=EB=AF=B8=EC=9D=B4=ED=96=89=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EC=99=80=20=EB=82=A0=EC=A7=9C=EB=B3=84=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=C2=B7=EC=9B=94=20=EC=9A=94=EC=95=BD=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../volunteer/service/VolunteerService.java | 199 +++++++++++++++++- 1 file changed, 198 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/service/VolunteerService.java b/src/main/java/com/checkup/checkup/domain/volunteer/service/VolunteerService.java index c661bdce..652ca372 100644 --- a/src/main/java/com/checkup/checkup/domain/volunteer/service/VolunteerService.java +++ b/src/main/java/com/checkup/checkup/domain/volunteer/service/VolunteerService.java @@ -3,10 +3,12 @@ import com.checkup.checkup.domain.member.entity.Student; import com.checkup.checkup.domain.member.repository.StudentRepository; import com.checkup.checkup.domain.volunteer.dto.response.VolunteerAdjustmentResponse; +import com.checkup.checkup.domain.volunteer.dto.response.VolunteerDutyRecordResponse; import com.checkup.checkup.domain.volunteer.dto.response.VolunteerResponse; import com.checkup.checkup.domain.notification.service.NotificationService; import com.checkup.checkup.domain.notification.entity.NotificationType; import com.checkup.checkup.domain.volunteer.entity.DutyStatus; +import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustment; import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustmentKind; import com.checkup.checkup.domain.volunteer.entity.VolunteerDuty; import com.checkup.checkup.domain.volunteer.repository.VolunteerAdjustmentRepository; @@ -24,9 +26,12 @@ import java.time.Clock; import java.time.Instant; import java.time.LocalDate; +import java.time.YearMonth; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Function; import java.util.function.ToIntFunction; import java.util.stream.Collectors; import java.util.regex.Pattern; @@ -212,7 +217,8 @@ public VolunteerResponse assignDuty(Long memberId, Long studentId) { * @throws CustomException 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403), * 저장된 학생이 없으면 {@link ErrorCode#STUDENT_NOT_FOUND}(404), * 오늘 지정되지 않았으면 {@link ErrorCode#NOT_ON_DUTY}(404), - * 이미 완료했으면 {@link ErrorCode#DUTY_ALREADY_COMPLETED}(409) + * 이미 완료했으면 {@link ErrorCode#DUTY_ALREADY_COMPLETED}(409), + * 미이행으로 처리했으면 {@link ErrorCode#DUTY_MISSED}(409) */ @Transactional public VolunteerResponse cancelDuty(Long memberId, Long studentId) { @@ -220,6 +226,7 @@ public VolunteerResponse cancelDuty(Long memberId, Long studentId) { Student student = findStudent(studentId); LocalDate today = operatingDayCalculator.today(); VolunteerDuty duty = findTodayDuty(student.getId(), today); + rejectMissed(duty); if (volunteerDutyRepository.cancel(duty.getId()) == 0) { throw new CustomException(ErrorCode.DUTY_ALREADY_COMPLETED); } @@ -238,6 +245,7 @@ public VolunteerResponse cancelDuty(Long memberId, Long studentId) { * 저장된 학생이 없으면 {@link ErrorCode#STUDENT_NOT_FOUND}(404), * 오늘 지정되지 않았으면 {@link ErrorCode#NOT_ON_DUTY}(404), * 이미 완료했으면 {@link ErrorCode#DUTY_ALREADY_COMPLETED}(409), + * 미이행으로 처리했으면 {@link ErrorCode#DUTY_MISSED}(409), * 그 사이 횟수가 0이 됐으면 {@link ErrorCode#VOLUNTEER_COUNT_ZERO}(409) */ @Transactional @@ -246,6 +254,7 @@ public VolunteerResponse completeDuty(Long memberId, Long studentId) { Long id = findStudent(studentId).getId(); Instant now = clock.instant(); VolunteerDuty duty = findTodayDuty(id, operatingDayCalculator.today()); + rejectMissed(duty); if (volunteerDutyRepository.complete(duty.getId(), now) == 0) { throw new CustomException(ErrorCode.DUTY_ALREADY_COMPLETED); } @@ -257,6 +266,194 @@ public VolunteerResponse completeDuty(Long memberId, Long studentId) { return reload(id); } + /** + * 한 운영일의 당일 봉사자 기록을 호실·이름·학번순으로 돌려준다(#250). 오늘 운영일도 같은 형태다. + * 운영일이 지났는데 아직 지정 상태인 지정은 미이행(횟수 미처리)으로 본다. + * + * @param memberId 세션의 회원 id + * @param operatingDay 조회할 운영일. {@code null}이면 오늘 운영일 + * @return 그날 지정된 학생들. 지정이 없으면 빈 목록 + * @throws CustomException 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403) + */ + @Transactional(readOnly = true) + public List getDutyRecords(Long memberId, LocalDate operatingDay) { + adminVerifier.verify(memberId); + LocalDate today = operatingDayCalculator.today(); + List duties = volunteerDutyRepository + .findAllWithStudentByOperatingDay(operatingDay == null ? today : operatingDay); + if (duties.isEmpty()) { + return List.of(); + } + Map adjustments = missedAdjustments(duties.stream().map(VolunteerDuty::getId).toList()); + return duties.stream() + .sorted(Comparator.comparing(VolunteerDuty::getStudent, ROOM_ORDER)) + .map(duty -> VolunteerDutyRecordResponse.of( + duty.getStudent(), recordStatus(duty, today), adjustments.get(duty.getId()))) + .toList(); + } + + /** + * 한 달에서 미이행이 있는 운영일과 그날의 미이행 인원을 날짜순으로 돌려준다(#250). 달력의 표시에 쓴다. + * 횟수를 처리한 미이행과 처리하지 않은 미이행을 함께 센다. + * + * @param memberId 세션의 회원 id + * @param month 조회할 달 + * @return 운영일 → 미이행 인원. 미이행이 없는 날은 없다 + * @throws CustomException 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403) + */ + @Transactional(readOnly = true) + public Map getMissedSummary(Long memberId, YearMonth month) { + adminVerifier.verify(memberId); + return volunteerDutyRepository + .countMissedByOperatingDay(month.atDay(1), month.atEndOfMonth(), operatingDayCalculator.today()) + .stream() + .collect(Collectors.toMap( + VolunteerDutyRepository.MissedDay::getOperatingDay, + VolunteerDutyRepository.MissedDay::getMissedCount, + (first, second) -> first, + LinkedHashMap::new)); + } + + /** + * 오늘 당일 봉사를 미이행으로 처리하고 봉사 횟수를 바꾼다(#250). 지정은 지우지 않는다. + * + * @param memberId 세션의 회원 id + * @param studentId DataGSM 학생 id + * @param requestKey 재시도 방지 키(선택). 같은 키로 다시 오면 반영하지 않고 현재 상태만 돌려준다. + * @param delta 바꿀 횟수(-99~99). 0이면 횟수는 그대로 두고 미이행만 기록한다 + * @param reason 사유(선택, 100자 이하) + * @return 처리 뒤 학생의 명단 항목 + * @throws CustomException 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403), + * 저장된 학생이 없으면 {@link ErrorCode#STUDENT_NOT_FOUND}(404), + * 오늘 지정되지 않았으면 {@link ErrorCode#NOT_ON_DUTY}(404), + * 그 밖에는 {@link #markMissed}와 같다 + */ + @Transactional + public VolunteerResponse markTodayMissed(Long memberId, Long studentId, String requestKey, int delta, String reason) { + Long id = recordMissed(memberId, studentId, operatingDayCalculator.today(), requestKey, delta, reason, + ErrorCode.NOT_ON_DUTY); + return reload(id); + } + + /** + * 한 운영일의 당일 봉사를 미이행으로 처리하고 봉사 횟수를 바꾼다(#250). 달력에서 횟수 미처리 미이행을 처리할 때 쓴다. + * 횟수를 이미 처리한 미이행은 다시 처리하지 않는다. 그 뒤의 조정은 일반 횟수 조정을 쓴다. + * + * @param memberId 세션의 회원 id + * @param studentId DataGSM 학생 id + * @param operatingDay 지정의 운영일 + * @param requestKey 재시도 방지 키(선택). 같은 키로 다시 오면 반영하지 않고 현재 상태만 돌려준다. + * @param delta 바꿀 횟수(-99~99). 0이면 횟수는 그대로 두고 미이행만 기록한다. 차감은 남은 횟수까지만 한다 + * @param reason 사유(선택, 100자 이하) + * @return 처리 뒤 그날의 기록 + * @throws CustomException 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403), + * 저장된 학생이 없으면 {@link ErrorCode#STUDENT_NOT_FOUND}(404), + * 그날 지정되지 않았으면 {@link ErrorCode#DUTY_NOT_FOUND}(404), + * 완료한 지정이면 {@link ErrorCode#DUTY_ALREADY_COMPLETED}(409), + * 횟수를 이미 처리했으면 {@link ErrorCode#DUTY_ALREADY_ADJUSTED}(409), + * 차감인데 횟수가 0이면 {@link ErrorCode#VOLUNTEER_COUNT_ZERO}(409), + * 키를 다른 요청에 썼으면 {@link ErrorCode#IDEMPOTENCY_KEY_REUSED}(409), + * 횟수·사유·키가 범위를 벗어나면 {@link ErrorCode#INVALID_REQUEST}(400) + */ + @Transactional + public VolunteerDutyRecordResponse markMissed( + Long memberId, Long studentId, LocalDate operatingDay, String requestKey, int delta, String reason) { + Long id = recordMissed(memberId, studentId, operatingDay, requestKey, delta, reason, ErrorCode.DUTY_NOT_FOUND); + VolunteerDuty duty = volunteerDutyRepository.findByStudentIdAndOperatingDay(id, operatingDay) + .orElseThrow(() -> new CustomException(ErrorCode.DUTY_NOT_FOUND)); + return VolunteerDutyRecordResponse.of(duty.getStudent(), recordStatus(duty, operatingDayCalculator.today()), + missedAdjustments(List.of(duty.getId())).get(duty.getId())); + } + + /** + * 지정을 잠가 미이행으로 바꾸고, 횟수를 바꿀 때는 그 지정에 연결한 조정 기록을 먼저 남긴다. + * 같은 지정의 요청은 지정 잠금으로 차례로 처리해 같은 키의 재시도가 한 번만 반영된다. + * + * @return 학생 DB id + */ + private Long recordMissed(Long memberId, Long studentId, LocalDate operatingDay, String requestKey, + int requestedDelta, String reason, ErrorCode notFound) { + adminVerifier.verify(memberId); + if (Math.abs(requestedDelta) > MAX_ADJUST_AMOUNT) { + throw new CustomException(ErrorCode.INVALID_REQUEST); + } + String key = normalizeKey(requestKey); + String savedReason = normalizeReason(reason); + Long id = findStudent(studentId).getId(); + + VolunteerDuty duty = volunteerDutyRepository.lockByStudentIdAndOperatingDay(id, operatingDay) + .orElseThrow(() -> new CustomException(notFound)); + if (key != null && isMissedRetry(key, duty.getId(), requestedDelta)) { + return id; + } + if (duty.getStatus() == DutyStatus.COMPLETED) { + throw new CustomException(ErrorCode.DUTY_ALREADY_COMPLETED); + } + if (volunteerAdjustmentRepository.existsByDuty_IdAndKind(duty.getId(), VolunteerAdjustmentKind.DUTY_MISSED)) { + throw new CustomException(ErrorCode.DUTY_ALREADY_ADJUSTED); + } + if (requestedDelta != 0) { + int count = studentRepository.lockVolunteerCount(id) + .orElseThrow(() -> new CustomException(ErrorCode.STUDENT_NOT_FOUND)); + int delta = requestedDelta > 0 ? requestedDelta : -Math.min(-requestedDelta, count); + if (delta == 0) { + throw new CustomException(ErrorCode.VOLUNTEER_COUNT_ZERO); + } + if (volunteerAdjustmentRepository.insertMissed( + id, duty.getId(), delta, requestedDelta, savedReason, key, clock.instant()) == 0) { + throw new CustomException(ErrorCode.IDEMPOTENCY_KEY_REUSED); + } + studentRepository.changeVolunteerCount(id, delta); + } + if (volunteerDutyRepository.markMissed(duty.getId()) == 0) { + throw new CustomException(ErrorCode.DUTY_ALREADY_COMPLETED); + } + return id; + } + + /** + * 같은 키의 기록이 이미 있으면 같은 지정·같은 요청 횟수의 미이행 재시도인지 확인한다. + * 다른 요청에 쓴 키면 반영되지 않았는데 성공으로 보이지 않도록 {@link ErrorCode#IDEMPOTENCY_KEY_REUSED}(409)다. + * + * @return 재시도면 {@code true}, 그 키의 기록이 없으면 {@code false} + */ + private boolean isMissedRetry(String key, Long dutyId, int requestedDelta) { + return volunteerAdjustmentRepository.findByRequestKey(key) + .map(existing -> { + boolean same = existing.getKind() == VolunteerAdjustmentKind.DUTY_MISSED + && existing.getDuty() != null + && existing.getDuty().getId().equals(dutyId) + && existing.getRequestedDelta() == requestedDelta; + if (!same) { + throw new CustomException(ErrorCode.IDEMPOTENCY_KEY_REUSED); + } + return true; + }) + .orElse(false); + } + + /** 지정 id → 그 지정의 미이행 조정 기록. 횟수를 처리하지 않은 지정은 없다. */ + private Map missedAdjustments(List dutyIds) { + return volunteerAdjustmentRepository + .findAllByDuty_IdInAndKind(dutyIds, VolunteerAdjustmentKind.DUTY_MISSED).stream() + .collect(Collectors.toMap(adjustment -> adjustment.getDuty().getId(), Function.identity())); + } + + /** 조회할 때 보는 지정 상태. 운영일이 지났는데 아직 지정 상태면 미이행이다. */ + private static DutyStatus recordStatus(VolunteerDuty duty, LocalDate today) { + if (duty.getStatus() == DutyStatus.ASSIGNED && duty.getOperatingDay().isBefore(today)) { + return DutyStatus.MISSED; + } + return duty.getStatus(); + } + + /** 미이행으로 처리한 지정은 완료·취소할 수 없다. 미이행 기록이 남아야 한다. */ + private static void rejectMissed(VolunteerDuty duty) { + if (duty.getStatus() == DutyStatus.MISSED) { + throw new CustomException(ErrorCode.DUTY_MISSED); + } + } + private VolunteerDuty findTodayDuty(Long studentId, LocalDate today) { return volunteerDutyRepository.findByStudentIdAndOperatingDay(studentId, today) .orElseThrow(() -> new CustomException(ErrorCode.NOT_ON_DUTY)); From 6afbc93a15bd3e722558d37c5f6dc1278777a444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:30:03 +0900 Subject: [PATCH 10/14] =?UTF-8?q?test:=20=EB=8B=B9=EC=9D=BC=20=EB=B4=89?= =?UTF-8?q?=EC=82=AC=20=EB=AF=B8=EC=9D=B4=ED=96=89=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EC=99=80=20=EB=82=A0=EC=A7=9C=EB=B3=84=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=C2=B7=EC=9B=94=20=EC=9A=94=EC=95=BD=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/VolunteerServiceTest.java | 261 +++++++++++++++++- 1 file changed, 260 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/checkup/checkup/domain/volunteer/service/VolunteerServiceTest.java b/src/test/java/com/checkup/checkup/domain/volunteer/service/VolunteerServiceTest.java index baf635ac..7f19f095 100644 --- a/src/test/java/com/checkup/checkup/domain/volunteer/service/VolunteerServiceTest.java +++ b/src/test/java/com/checkup/checkup/domain/volunteer/service/VolunteerServiceTest.java @@ -18,6 +18,7 @@ import com.checkup.checkup.domain.member.repository.StudentRepository; import com.checkup.checkup.domain.volunteer.dto.response.VolunteerResponse; import com.checkup.checkup.domain.volunteer.dto.response.VolunteerAdjustmentResponse; +import com.checkup.checkup.domain.volunteer.dto.response.VolunteerDutyRecordResponse; import com.checkup.checkup.domain.volunteer.entity.DutyStatus; import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustment; import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustmentKind; @@ -33,8 +34,10 @@ import java.time.Clock; import java.time.Instant; import java.time.LocalDate; +import java.time.YearMonth; import java.time.ZoneOffset; import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -47,7 +50,7 @@ /** * 봉사 관리 명단이 관리자에게만 전체 학생을 봉사 횟수와 함께 보여주고, * 횟수 조정이 남은 횟수까지만 빼며 사유·요청 횟수를 기록하고 재시도를 한 번만 반영하는지(#139), - * 조정 이력을 관리자에게 최신순으로 돌려주는지(#140) 검증한다. + * 조정 이력을 관리자에게 최신순으로 돌려주는지(#140), 당일 봉사 미이행 처리와 날짜별 기록·월 요약(#250)을 검증한다. */ class VolunteerServiceTest { @@ -578,6 +581,220 @@ void nonAdminIsRejected() { assertThatThrownBy(() -> service.cancelDuty(MEMBER_ID, 200L)).isInstanceOf(CustomException.class); assertThatThrownBy(() -> service.completeDuty(MEMBER_ID, 200L)).isInstanceOf(CustomException.class); verify(volunteerDutyRepository, never()).assign(anyLong(), any(), any()); + assertThatThrownBy(() -> service.getDutyRecords(MEMBER_ID, TODAY)).isInstanceOf(CustomException.class); + assertThatThrownBy(() -> service.getMissedSummary(MEMBER_ID, YearMonth.of(2026, 10))).isInstanceOf(CustomException.class); + assertThatThrownBy(() -> service.markTodayMissed(MEMBER_ID, 200L, null, 1, null)).isInstanceOf(CustomException.class); + assertThatThrownBy(() -> service.markMissed(MEMBER_ID, 200L, TODAY, null, 1, null)).isInstanceOf(CustomException.class); + verify(volunteerDutyRepository, never()).findAllWithStudentByOperatingDay(any()); + verify(volunteerDutyRepository, never()).lockByStudentIdAndOperatingDay(anyLong(), any()); + } + + @Test + @DisplayName("날짜별 기록은 호실순이고, 지난 운영일의 지정은 미이행(횟수 미처리)으로, 처리한 미이행은 추가 횟수·사유와 함께 보인다") + void dutyRecordsShowMissedAndAdjustment() { + LocalDate yesterday = TODAY.minusDays(1); + Student first = student(1L, "가학생", 2101, 301); + Student second = student(2L, "나학생", 2102, 302); + Student third = student(3L, "다학생", 2103, 303); + VolunteerDuty missed = duty(61L, second, yesterday, DutyStatus.MISSED); + VolunteerDuty unprocessed = duty(62L, third, yesterday, DutyStatus.ASSIGNED); + VolunteerDuty completed = duty(63L, first, yesterday, DutyStatus.COMPLETED); + given(volunteerDutyRepository.findAllWithStudentByOperatingDay(yesterday)) + .willReturn(List.of(missed, unprocessed, completed)); + given(volunteerAdjustmentRepository.findAllByDuty_IdInAndKind(List.of(61L, 62L, 63L), VolunteerAdjustmentKind.DUTY_MISSED)) + .willReturn(List.of(missedAdjustment(missed, 2, "당일 봉사 미이행"))); + + assertThat(service.getDutyRecords(MEMBER_ID, yesterday)).containsExactly( + new VolunteerDutyRecordResponse(1L, 2101, "가학생", 301, DutyStatus.COMPLETED, false, null, null), + new VolunteerDutyRecordResponse(2L, 2102, "나학생", 302, DutyStatus.MISSED, true, 2, "당일 봉사 미이행"), + new VolunteerDutyRecordResponse(3L, 2103, "다학생", 303, DutyStatus.MISSED, false, null, null)); + } + + @Test + @DisplayName("날짜가 없으면 오늘 운영일을 보고, 오늘 지정은 아직 지정 상태로 보인다") + void dutyRecordsDefaultToTodayAndKeepAssigned() { + Student student = student(1L, "학생", 2101, 301); + given(volunteerDutyRepository.findAllWithStudentByOperatingDay(TODAY)) + .willReturn(List.of(duty(61L, student, TODAY, DutyStatus.ASSIGNED))); + + assertThat(service.getDutyRecords(MEMBER_ID, null)) + .extracting(VolunteerDutyRecordResponse::status) + .containsExactly(DutyStatus.ASSIGNED); + } + + @Test + @DisplayName("그날 지정이 없으면 빈 목록이고 조정 기록을 읽지 않는다") + void dutyRecordsOfEmptyDay() { + assertThat(service.getDutyRecords(MEMBER_ID, TODAY.minusDays(3))).isEmpty(); + verify(volunteerAdjustmentRepository, never()).findAllByDuty_IdInAndKind(any(), any()); + } + + @Test + @DisplayName("월 요약은 그 달의 첫날~말일과 오늘 운영일로 세어 날짜순으로 돌려준다") + void missedSummaryOfMonth() { + given(volunteerDutyRepository.countMissedByOperatingDay( + LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 30), TODAY)) + .willReturn(List.of(missedDay(LocalDate.of(2026, 9, 13), 1), missedDay(LocalDate.of(2026, 9, 14), 2))); + + Map summary = service.getMissedSummary(MEMBER_ID, YearMonth.of(2026, 9)); + + assertThat(summary).containsExactly( + Map.entry(LocalDate.of(2026, 9, 13), 1L), Map.entry(LocalDate.of(2026, 9, 14), 2L)); + } + + @Test + @DisplayName("오늘 미이행은 지정에 연결한 조정을 남기고 횟수를 늘린 뒤 지정을 미이행으로 바꾼다") + void markTodayMissedAddsCount() { + givenAdjustable(1); + givenLockedDuty(TODAY, DutyStatus.ASSIGNED); + given(volunteerAdjustmentRepository.insertMissed(10L, 50L, 1, 1, "당일 봉사 미이행", "key-m", NOW)).willReturn(1); + given(volunteerDutyRepository.markMissed(50L)).willReturn(1); + + service.markTodayMissed(MEMBER_ID, 200L, "key-m", 1, " 당일 봉사 미이행 "); + + verify(studentRepository).changeVolunteerCount(10L, 1); + verify(volunteerDutyRepository).markMissed(50L); + verify(notificationService, never()).delete(anyLong(), any(), any()); + } + + @Test + @DisplayName("횟수 0으로 처리하면 조정 기록·횟수 변경 없이 미이행만 남긴다") + void markMissedWithZeroOnlyRecordsMissed() { + Student student = givenAdjustable(1); + VolunteerDuty duty = givenLockedDuty(TODAY.minusDays(1), DutyStatus.ASSIGNED); + ReflectionTestUtils.setField(duty, "student", student); + given(volunteerDutyRepository.markMissed(50L)).willReturn(1); + given(volunteerDutyRepository.findByStudentIdAndOperatingDay(10L, TODAY.minusDays(1))).willReturn(Optional.of(duty)); + + assertThat(service.markMissed(MEMBER_ID, 200L, TODAY.minusDays(1), null, 0, null).adjusted()).isFalse(); + + verify(volunteerAdjustmentRepository, never()).insertMissed(anyLong(), anyLong(), anyInt(), anyInt(), any(), any(), any()); + verify(studentRepository, never()).changeVolunteerCount(anyLong(), anyInt()); + verify(volunteerDutyRepository).markMissed(50L); + } + + @Test + @DisplayName("지난 날의 횟수 미처리 미이행을 처리하면 그날의 기록을 횟수 처리됨으로 돌려준다") + void markMissedReturnsAdjustedRecord() { + LocalDate yesterday = TODAY.minusDays(1); + Student student = givenAdjustable(1); + VolunteerDuty duty = givenLockedDuty(yesterday, DutyStatus.MISSED); + ReflectionTestUtils.setField(duty, "student", student); + given(volunteerAdjustmentRepository.insertMissed(10L, 50L, 2, 2, null, null, NOW)).willReturn(1); + given(volunteerDutyRepository.markMissed(50L)).willReturn(1); + given(volunteerDutyRepository.findByStudentIdAndOperatingDay(10L, yesterday)).willReturn(Optional.of(duty)); + given(volunteerAdjustmentRepository.findAllByDuty_IdInAndKind(List.of(50L), VolunteerAdjustmentKind.DUTY_MISSED)) + .willReturn(List.of(missedAdjustment(duty, 2, null))); + + VolunteerDutyRecordResponse record = service.markMissed(MEMBER_ID, 200L, yesterday, null, 2, null); + + assertThat(record).isEqualTo( + new VolunteerDutyRecordResponse(200L, 2105, "학생", 301, DutyStatus.MISSED, true, 2, null)); + verify(studentRepository).changeVolunteerCount(10L, 2); + } + + @Test + @DisplayName("차감으로 처리하면 남은 횟수까지만 빼고, 남은 횟수가 0이면 409 VOLUNTEER_COUNT_ZERO다") + void markMissedClampsDecrease() { + givenAdjustable(1); + givenLockedDuty(TODAY, DutyStatus.ASSIGNED); + given(volunteerAdjustmentRepository.insertMissed(10L, 50L, -1, -3, null, null, NOW)).willReturn(1); + given(volunteerDutyRepository.markMissed(50L)).willReturn(1); + + service.markTodayMissed(MEMBER_ID, 200L, null, -3, null); + + verify(studentRepository).changeVolunteerCount(10L, -1); + + given(studentRepository.lockVolunteerCount(10L)).willReturn(Optional.of(0)); + assertThatThrownBy(() -> service.markTodayMissed(MEMBER_ID, 200L, null, -1, null)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.VOLUNTEER_COUNT_ZERO)); + } + + @Test + @DisplayName("완료한 지정은 409 DUTY_ALREADY_COMPLETED로 미이행 처리할 수 없다") + void markMissedOfCompletedIsRejected() { + givenAdjustable(1); + givenLockedDuty(TODAY, DutyStatus.COMPLETED); + + assertThatThrownBy(() -> service.markTodayMissed(MEMBER_ID, 200L, null, 1, null)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.DUTY_ALREADY_COMPLETED)); + verify(studentRepository, never()).changeVolunteerCount(anyLong(), anyInt()); + verify(volunteerDutyRepository, never()).markMissed(anyLong()); + } + + @Test + @DisplayName("횟수를 이미 처리한 미이행은 409 DUTY_ALREADY_ADJUSTED로 다시 처리하지 않는다") + void markMissedTwiceIsRejected() { + givenAdjustable(2); + givenLockedDuty(TODAY.minusDays(1), DutyStatus.MISSED); + given(volunteerAdjustmentRepository.existsByDuty_IdAndKind(50L, VolunteerAdjustmentKind.DUTY_MISSED)).willReturn(true); + + assertThatThrownBy(() -> service.markMissed(MEMBER_ID, 200L, TODAY.minusDays(1), null, 1, null)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.DUTY_ALREADY_ADJUSTED)); + verify(studentRepository, never()).changeVolunteerCount(anyLong(), anyInt()); + } + + @Test + @DisplayName("같은 키로 다시 오면 반영하지 않고 현재 상태를 돌려주고, 다른 요청에 쓴 키는 409 IDEMPOTENCY_KEY_REUSED다") + void markMissedRetryWithSameKey() { + givenAdjustable(2); + VolunteerDuty duty = givenLockedDuty(TODAY, DutyStatus.MISSED); + given(volunteerAdjustmentRepository.findByRequestKey("key-m")).willReturn(Optional.of(missedAdjustment(duty, 1, null))); + + service.markTodayMissed(MEMBER_ID, 200L, "key-m", 1, null); + + verify(volunteerAdjustmentRepository, never()).insertMissed(anyLong(), anyLong(), anyInt(), anyInt(), any(), any(), any()); + verify(volunteerDutyRepository, never()).markMissed(anyLong()); + + assertThatThrownBy(() -> service.markTodayMissed(MEMBER_ID, 200L, "key-m", 2, null)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.IDEMPOTENCY_KEY_REUSED)); + given(volunteerAdjustmentRepository.findByRequestKey("key-a")).willReturn(Optional.of(adjustment(10L, 1))); + assertThatThrownBy(() -> service.markTodayMissed(MEMBER_ID, 200L, "key-a", 1, null)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.IDEMPOTENCY_KEY_REUSED)); + } + + @Test + @DisplayName("지정이 없으면 오늘 처리는 404 NOT_ON_DUTY, 날짜 처리는 404 DUTY_NOT_FOUND다") + void markMissedWithoutDutyIsRejected() { + givenAdjustable(1); + + assertThatThrownBy(() -> service.markTodayMissed(MEMBER_ID, 200L, null, 1, null)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.NOT_ON_DUTY)); + assertThatThrownBy(() -> service.markMissed(MEMBER_ID, 200L, TODAY.minusDays(1), null, 1, null)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.DUTY_NOT_FOUND)); + } + + @ParameterizedTest + @ValueSource(ints = {-100, 100}) + @DisplayName("미이행 횟수가 -99~99를 벗어나면 400 INVALID_REQUEST이고 지정을 읽지 않는다") + void markMissedRejectsInvalidDelta(int delta) { + assertThatThrownBy(() -> service.markTodayMissed(MEMBER_ID, 200L, null, delta, null)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.INVALID_REQUEST)); + verify(volunteerDutyRepository, never()).lockByStudentIdAndOperatingDay(anyLong(), any()); + } + + @Test + @DisplayName("미이행으로 처리한 지정은 409 DUTY_MISSED로 완료·취소할 수 없다") + void missedDutyCannotBeCompletedOrCancelled() { + givenAdjustable(2); + givenTodayDuty(DutyStatus.MISSED); + + assertThatThrownBy(() -> service.completeDuty(MEMBER_ID, 200L)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.DUTY_MISSED)); + assertThatThrownBy(() -> service.cancelDuty(MEMBER_ID, 200L)) + .isInstanceOfSatisfying(CustomException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.DUTY_MISSED)); + verify(volunteerDutyRepository, never()).complete(anyLong(), any()); + verify(volunteerDutyRepository, never()).cancel(anyLong()); } private void givenStudents(Student... students) { @@ -639,4 +856,46 @@ private static VolunteerAdjustment adjustment(Long studentId, int delta, int req ReflectionTestUtils.setField(adjustment, "requestedDelta", (short) requestedDelta); return adjustment; } + + private VolunteerDuty givenLockedDuty(LocalDate day, DutyStatus status) { + VolunteerDuty duty = BeanUtils.instantiateClass(VolunteerDuty.class); + ReflectionTestUtils.setField(duty, "id", 50L); + ReflectionTestUtils.setField(duty, "operatingDay", day); + ReflectionTestUtils.setField(duty, "status", status); + given(volunteerDutyRepository.lockByStudentIdAndOperatingDay(10L, day)).willReturn(Optional.of(duty)); + return duty; + } + + private static VolunteerDuty duty(Long id, Student student, LocalDate day, DutyStatus status) { + VolunteerDuty duty = BeanUtils.instantiateClass(VolunteerDuty.class); + ReflectionTestUtils.setField(duty, "id", id); + ReflectionTestUtils.setField(duty, "student", student); + ReflectionTestUtils.setField(duty, "operatingDay", day); + ReflectionTestUtils.setField(duty, "status", status); + return duty; + } + + private static VolunteerAdjustment missedAdjustment(VolunteerDuty duty, int delta, String reason) { + VolunteerAdjustment adjustment = BeanUtils.instantiateClass(VolunteerAdjustment.class); + ReflectionTestUtils.setField(adjustment, "duty", duty); + ReflectionTestUtils.setField(adjustment, "delta", (short) delta); + ReflectionTestUtils.setField(adjustment, "requestedDelta", (short) delta); + ReflectionTestUtils.setField(adjustment, "reason", reason); + ReflectionTestUtils.setField(adjustment, "kind", VolunteerAdjustmentKind.DUTY_MISSED); + return adjustment; + } + + private static VolunteerDutyRepository.MissedDay missedDay(LocalDate day, long count) { + return new VolunteerDutyRepository.MissedDay() { + @Override + public LocalDate getOperatingDay() { + return day; + } + + @Override + public long getMissedCount() { + return count; + } + }; + } } From 1f396dc8bc0405fa89f6e26c097e7f1354d8a999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:50:30 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=EB=8B=B9=EC=9D=BC=20=EB=B4=89?= =?UTF-8?q?=EC=82=AC=20=EB=AF=B8=EC=9D=B4=ED=96=89=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EC=99=80=20=EB=82=A0=EC=A7=9C=EB=B3=84=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=C2=B7=EC=9B=94=20=EC=9A=94=EC=95=BD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/VolunteerController.java | 82 ++++++++++++++++++- .../response/VolunteerAdjustmentResponse.java | 4 +- .../dto/response/VolunteerResponse.java | 4 +- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/controller/VolunteerController.java b/src/main/java/com/checkup/checkup/domain/volunteer/controller/VolunteerController.java index c14e2bcd..6c71ebcb 100644 --- a/src/main/java/com/checkup/checkup/domain/volunteer/controller/VolunteerController.java +++ b/src/main/java/com/checkup/checkup/domain/volunteer/controller/VolunteerController.java @@ -4,11 +4,14 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import com.checkup.checkup.domain.volunteer.dto.request.VolunteerAdjustRequest; +import com.checkup.checkup.domain.volunteer.dto.request.VolunteerMissedRequest; import com.checkup.checkup.domain.volunteer.dto.response.VolunteerAdjustmentResponse; +import com.checkup.checkup.domain.volunteer.dto.response.VolunteerDutyRecordResponse; import com.checkup.checkup.domain.volunteer.dto.response.VolunteerResponse; import com.checkup.checkup.domain.volunteer.service.VolunteerService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -21,12 +24,15 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDate; +import java.time.YearMonth; import java.util.List; +import java.util.Map; /** * 관리자 봉사 관리 API(DEC-020). 사감·기숙사 자치위원이 전체 학생 명단을 보고 봉사 횟수를 조정한다. */ -@Tag(name = "봉사 관리", description = "사감·기숙사 자치위원의 봉사 관리 명단·횟수 조정·당일 봉사자(REQ-COM-001·002·006, DEC-020·021). 경로의 studentId는 DataGSM 학생 id") +@Tag(name = "봉사 관리", description = "사감·기숙사 자치위원의 봉사 관리 명단·횟수 조정·당일 봉사자·미이행(REQ-COM-001·002·006, DEC-020·021, #250). 경로의 studentId는 DataGSM 학생 id") @RestController @RequestMapping("/api/v1/volunteer") @RequiredArgsConstructor @@ -168,4 +174,78 @@ public VolunteerResponse cancelDuty(@AuthenticationPrincipal Long memberId, @Par public VolunteerResponse completeDuty(@AuthenticationPrincipal Long memberId, @Parameter(description = "DataGSM 학생 id(명단의 studentId). DB id가 아니다", example = "1") @PathVariable Long studentId) { return volunteerService.completeDuty(memberId, studentId); } + + /** + * 오늘 당일 봉사를 미이행으로 처리하고 봉사 횟수를 바꾼다(#250). 지정은 지우지 않고 미이행으로 남긴다. + * + * @param memberId 세션의 회원 id + * @param studentId DataGSM 학생 id + * @param idempotencyKey 재시도 방지 키(선택, 100자 이하) + * @param request 바꿀 횟수와 사유 + * @return 처리 뒤 학생의 명단 항목 + */ + @Operation(summary = "당일 봉사 미이행", description = "오늘 지정을 MISSED로 바꾸고 delta만큼 횟수를 바꾸며 사유와 함께 조정 이력(kind=DUTY_MISSED)에 남긴다. delta는 -99~99이고 0이면 횟수는 그대로 두고 미이행만 기록한다. 차감은 남은 횟수까지만 한다. 지정이 없으면 404 NOT_ON_DUTY, 완료했으면 409 DUTY_ALREADY_COMPLETED, 횟수를 이미 처리했으면 409 DUTY_ALREADY_ADJUSTED. Idempotency-Key는 횟수 조정과 같다. 학생 알림은 만들거나 지우지 않는다.") + @PostMapping("/{studentId}/duty/missed") + public VolunteerResponse markTodayMissed( + @AuthenticationPrincipal Long memberId, + @Parameter(description = "DataGSM 학생 id(명단의 studentId). DB id가 아니다", example = "1") @PathVariable Long studentId, + @Parameter(description = "재시도 중복 방지 키(선택, 최대 100자). 같은 키로 다시 보내면 한 번만 반영한다", example = "9f0c1e2a-3b4d-4e5f-8a9b-0c1d2e3f4a5b") @RequestHeader(value = IDEMPOTENCY_KEY, required = false) String idempotencyKey, + @Valid @RequestBody VolunteerMissedRequest request + ) { + return volunteerService.markTodayMissed(memberId, studentId, idempotencyKey, request.delta(), request.reason()); + } + + /** + * 한 운영일의 당일 봉사자 기록을 호실·이름·학번순으로 돌려준다(#250). 달력에서 날짜를 고를 때 쓴다. + * + * @param memberId 세션의 회원 id + * @param date 운영일(08:00 KST 기준 날짜). 없으면 오늘 운영일 + * @return 그날 지정된 학생들 + */ + @Operation(summary = "날짜별 당일 봉사자 기록", description = "관리자 전용. 그 운영일에 지정된 학생과 상태(ASSIGNED·COMPLETED·MISSED), 미이행 횟수 처리 여부(adjusted)·추가한 횟수(addedCount)·사유(reason)를 호실순으로 돌려준다. 운영일이 지났는데 처리가 없던 지정은 MISSED, adjusted=false다. 오늘 지정은 처리 전이면 ASSIGNED다. 취소한 지정은 지워지므로 나오지 않는다.") + @GetMapping("/duties") + public List getDutyRecords( + @AuthenticationPrincipal Long memberId, + @Parameter(description = "운영일(선택, yyyy-MM-dd). 없으면 오늘 운영일", example = "2026-10-14") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date + ) { + return volunteerService.getDutyRecords(memberId, date); + } + + /** + * 한 달에서 미이행이 있는 운영일과 그날의 미이행 인원을 돌려준다(#250). 달력의 표시에 쓴다. + * + * @param memberId 세션의 회원 id + * @param month 조회할 달 + * @return 운영일 → 미이행 인원 + */ + @Operation(summary = "월 단위 미이행 요약", description = "관리자 전용. {\"2026-10-13\": 1, \"2026-10-14\": 2}처럼 미이행이 있는 운영일만 날짜순으로 담는다. 횟수를 처리한 미이행과 처리하지 않은 미이행을 함께 센다.") + @GetMapping("/duties/summary") + public Map getMissedSummary( + @AuthenticationPrincipal Long memberId, + @Parameter(description = "조회할 달(yyyy-MM)", example = "2026-10") @RequestParam @DateTimeFormat(pattern = "yyyy-MM") YearMonth month + ) { + return volunteerService.getMissedSummary(memberId, month); + } + + /** + * 한 운영일의 당일 봉사를 미이행으로 처리하고 봉사 횟수를 바꾼다(#250). 달력에서 횟수 미처리 미이행을 처리할 때 쓴다. + * + * @param memberId 세션의 회원 id + * @param date 지정의 운영일 + * @param studentId DataGSM 학생 id + * @param idempotencyKey 재시도 방지 키(선택, 100자 이하) + * @param request 바꿀 횟수와 사유 + * @return 처리 뒤 그날의 기록 + */ + @Operation(summary = "날짜별 미이행 처리", description = "그날의 지정(지정됨 또는 횟수 미처리 미이행)을 MISSED로 바꾸고 횟수·사유를 저장한다. 본문과 Idempotency-Key는 당일 봉사 미이행과 같다. 지정이 없으면 404 DUTY_NOT_FOUND, 완료했으면 409 DUTY_ALREADY_COMPLETED, 횟수를 이미 처리했으면 409 DUTY_ALREADY_ADJUSTED(학생 관리의 횟수 조정을 쓴다).") + @PatchMapping("/duties/{date}/{studentId}/missed") + public VolunteerDutyRecordResponse markMissed( + @AuthenticationPrincipal Long memberId, + @Parameter(description = "지정의 운영일(yyyy-MM-dd)", example = "2026-10-14") @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @Parameter(description = "DataGSM 학생 id(명단의 studentId). DB id가 아니다", example = "1") @PathVariable Long studentId, + @Parameter(description = "재시도 중복 방지 키(선택, 최대 100자). 같은 키로 다시 보내면 한 번만 반영한다", example = "9f0c1e2a-3b4d-4e5f-8a9b-0c1d2e3f4a5b") @RequestHeader(value = IDEMPOTENCY_KEY, required = false) String idempotencyKey, + @Valid @RequestBody VolunteerMissedRequest request + ) { + return volunteerService.markMissed(memberId, studentId, date, idempotencyKey, request.delta(), request.reason()); + } } diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerAdjustmentResponse.java b/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerAdjustmentResponse.java index d8534c2d..4af07888 100644 --- a/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerAdjustmentResponse.java +++ b/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerAdjustmentResponse.java @@ -19,8 +19,8 @@ public record VolunteerAdjustmentResponse( @Schema(description = "조정한 시각") Instant createdAt, @Schema(description = "실제로 바뀐 횟수. 양수는 추가, 음수는 차감(당일 봉사 완료 포함)", example = "-2") int delta, @Schema(description = "관리자가 요청한 횟수. 차감은 남은 횟수까지만 하므로 delta와 다를 수 있다", example = "-5") int requestedDelta, - @Schema(description = "사유. 남기지 않았으면 null. 당일 봉사 완료(DUTY_COMPLETION)는 항상 null", example = "청소 당번 대체") String reason, - @Schema(description = "조정 종류. ADMIN(관리자 +/−·여러 회 조정) 또는 DUTY_COMPLETION(당일 봉사 완료로 생긴 -1). 사유가 없으면 화면은 종류별 기본 문구를 활동명으로 쓴다", example = "ADMIN") VolunteerAdjustmentKind kind + @Schema(description = "사유. 남기지 않았으면 null. 당일 봉사 완료(DUTY_COMPLETION)는 항상 null, 미이행(DUTY_MISSED)은 처리할 때 남긴 사유", example = "청소 당번 대체") String reason, + @Schema(description = "조정 종류. ADMIN(관리자 +/−·여러 회 조정) 또는 DUTY_COMPLETION(당일 봉사 완료로 생긴 -1), DUTY_MISSED(당일 봉사 미이행으로 바꾼 횟수). 사유가 없으면 화면은 종류별 기본 문구를 활동명으로 쓴다", example = "ADMIN") VolunteerAdjustmentKind kind ) { public static VolunteerAdjustmentResponse from(VolunteerAdjustment adjustment) { diff --git a/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerResponse.java b/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerResponse.java index 7d464a68..77172521 100644 --- a/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerResponse.java +++ b/src/main/java/com/checkup/checkup/domain/volunteer/dto/response/VolunteerResponse.java @@ -16,7 +16,7 @@ * @param volunteerCount 앞으로 해야 할 봉사 횟수 * @param lastActivityAt 마지막 횟수 조정 시각. 조정한 적 없으면 {@code null}(화면은 {@code -}) * @param todayDuty 오늘(운영일) 당일 봉사자 지정 상태. 지정되지 않았으면 {@code null}. - * 웹은 {@code null}이면 "당일 봉사자 지정", {@code ASSIGNED}면 "지정됨(취소)·완료", {@code COMPLETED}면 "완료됨"을 보여준다. + * 웹은 {@code null}이면 "당일 봉사자 지정", {@code ASSIGNED}면 "지정됨(취소)·완료", {@code COMPLETED}면 "완료됨", {@code MISSED}면 "미이행"을 보여준다. */ public record VolunteerResponse( @Schema(description = "DataGSM 학생 id. 횟수 조정·당일 봉사 API의 경로 값이다", example = "1") Long studentId, @@ -25,7 +25,7 @@ public record VolunteerResponse( @Schema(description = "호실. 미배정이면 null", example = "301") Integer dormitoryRoom, @Schema(description = "앞으로 해야 할 봉사 횟수", example = "2") int volunteerCount, @Schema(description = "마지막 횟수 조정 시각. 조정한 적 없으면 null(화면은 -)") Instant lastActivityAt, - @Schema(description = "오늘 운영일 당일 봉사자 지정 상태. 지정되지 않았으면 null, ASSIGNED(지정됨), COMPLETED(완료됨)", example = "ASSIGNED") DutyStatus todayDuty + @Schema(description = "오늘 운영일 당일 봉사자 지정 상태. 지정되지 않았으면 null, ASSIGNED(지정됨), COMPLETED(완료됨), MISSED(미이행)", example = "ASSIGNED") DutyStatus todayDuty ) { /** From b7e30fb1da9f708747afc98f17f94f78487b0cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:50:42 +0900 Subject: [PATCH 12/14] =?UTF-8?q?test:=20=EB=8B=B9=EC=9D=BC=20=EB=B4=89?= =?UTF-8?q?=EC=82=AC=20=EB=AF=B8=EC=9D=B4=ED=96=89=C2=B7=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=B3=84=20=EA=B8=B0=EB=A1=9D=C2=B7=EC=9B=94=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/VolunteerControllerTest.java | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/test/java/com/checkup/checkup/domain/volunteer/controller/VolunteerControllerTest.java b/src/test/java/com/checkup/checkup/domain/volunteer/controller/VolunteerControllerTest.java index 12f86925..03963f79 100644 --- a/src/test/java/com/checkup/checkup/domain/volunteer/controller/VolunteerControllerTest.java +++ b/src/test/java/com/checkup/checkup/domain/volunteer/controller/VolunteerControllerTest.java @@ -15,6 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.checkup.checkup.domain.volunteer.dto.response.VolunteerAdjustmentResponse; +import com.checkup.checkup.domain.volunteer.dto.response.VolunteerDutyRecordResponse; import com.checkup.checkup.domain.volunteer.dto.response.VolunteerResponse; import com.checkup.checkup.domain.volunteer.entity.VolunteerAdjustmentKind; import com.checkup.checkup.domain.volunteer.entity.DutyStatus; @@ -24,7 +25,11 @@ import com.checkup.checkup.global.exception.GlobalExceptionHandler; import com.checkup.checkup.global.security.SecurityConfig; import java.time.Instant; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -47,6 +52,7 @@ class VolunteerControllerTest { private static final Long MEMBER_ID = 1L; private static final String BASE = "/api/v1/volunteer"; + private static final String MISSED_BODY = "{\"delta\":1,\"reason\":\"당일 봉사 미이행\"}"; @Autowired private MockMvc mockMvc; @@ -240,6 +246,12 @@ void withoutLoginReturnsUnauthorized() throws Exception { mockMvc.perform(post(BASE + "/200/duty")).andExpect(status().isUnauthorized()); mockMvc.perform(delete(BASE + "/200/duty")).andExpect(status().isUnauthorized()); mockMvc.perform(post(BASE + "/200/duty/complete")).andExpect(status().isUnauthorized()); + mockMvc.perform(get(BASE + "/duties")).andExpect(status().isUnauthorized()); + mockMvc.perform(get(BASE + "/duties/summary").param("month", "2026-10")).andExpect(status().isUnauthorized()); + mockMvc.perform(post(BASE + "/200/duty/missed") + .contentType(MediaType.APPLICATION_JSON).content(MISSED_BODY)).andExpect(status().isUnauthorized()); + mockMvc.perform(patch(BASE + "/duties/2026-10-14/200/missed") + .contentType(MediaType.APPLICATION_JSON).content(MISSED_BODY)).andExpect(status().isUnauthorized()); verify(volunteerService, never()).getVolunteers(any(), any(), any(), any(), any()); verify(volunteerService, never()).increase(any(), any(), any()); @@ -247,6 +259,112 @@ void withoutLoginReturnsUnauthorized() throws Exception { verify(volunteerService, never()).assignDuty(any(), any()); verify(volunteerService, never()).cancelDuty(any(), any()); verify(volunteerService, never()).completeDuty(any(), any()); + verify(volunteerService, never()).getDutyRecords(any(), any()); + verify(volunteerService, never()).getMissedSummary(any(), any()); + verify(volunteerService, never()).markTodayMissed(any(), any(), any(), anyInt(), any()); + verify(volunteerService, never()).markMissed(any(), any(), any(), any(), anyInt(), any()); + } + + @Test + @DisplayName("날짜별 기록은 운영일을 서비스로 넘기고 상태·처리 여부·추가 횟수·사유를 배열로 응답한다") + void dutyRecordsAreReturned() throws Exception { + given(volunteerService.getDutyRecords(MEMBER_ID, LocalDate.of(2026, 10, 14))).willReturn(List.of( + new VolunteerDutyRecordResponse(3203L, 3203, "류하준", 302, DutyStatus.MISSED, true, 1, "당일 봉사 미이행"), + new VolunteerDutyRecordResponse(3115L, 3115, "서지안", null, DutyStatus.MISSED, false, null, null))); + + mockMvc.perform(get(BASE + "/duties").param("date", "2026-10-14").with(loginAs(MEMBER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].studentId").value(3203)) + .andExpect(jsonPath("$[0].studentNumber").value(3203)) + .andExpect(jsonPath("$[0].name").value("류하준")) + .andExpect(jsonPath("$[0].dormitoryRoom").value(302)) + .andExpect(jsonPath("$[0].status").value("MISSED")) + .andExpect(jsonPath("$[0].adjusted").value(true)) + .andExpect(jsonPath("$[0].addedCount").value(1)) + .andExpect(jsonPath("$[0].reason").value("당일 봉사 미이행")) + .andExpect(jsonPath("$[1].dormitoryRoom").isEmpty()) + .andExpect(jsonPath("$[1].adjusted").value(false)) + .andExpect(jsonPath("$[1].addedCount").isEmpty()); + } + + @Test + @DisplayName("날짜가 없으면 null로 넘기고, 날짜 형식이 틀리면 400이다") + void dutyRecordsDateIsOptionalAndValidated() throws Exception { + mockMvc.perform(get(BASE + "/duties").with(loginAs(MEMBER_ID))).andExpect(status().isOk()); + mockMvc.perform(get(BASE + "/duties").param("date", "2026-13-01").with(loginAs(MEMBER_ID))) + .andExpect(status().isBadRequest()); + + verify(volunteerService).getDutyRecords(MEMBER_ID, null); + } + + @Test + @DisplayName("월 요약은 yyyy-MM 달을 넘기고 날짜를 키로 한 객체로 응답하며, 달이 없거나 틀리면 400이다") + void missedSummaryIsReturnedAsObject() throws Exception { + Map summary = new LinkedHashMap<>(); + summary.put(LocalDate.of(2026, 10, 13), 1L); + summary.put(LocalDate.of(2026, 10, 14), 2L); + given(volunteerService.getMissedSummary(MEMBER_ID, YearMonth.of(2026, 10))).willReturn(summary); + + mockMvc.perform(get(BASE + "/duties/summary").param("month", "2026-10").with(loginAs(MEMBER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$['2026-10-13']").value(1)) + .andExpect(jsonPath("$['2026-10-14']").value(2)); + mockMvc.perform(get(BASE + "/duties/summary").with(loginAs(MEMBER_ID))).andExpect(status().isBadRequest()); + mockMvc.perform(get(BASE + "/duties/summary").param("month", "2026-1").with(loginAs(MEMBER_ID))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("오늘 미이행은 본문의 횟수·사유와 Idempotency-Key를 넘기고 바뀐 항목을 응답한다") + void markTodayMissedPassesBody() throws Exception { + given(volunteerService.markTodayMissed(MEMBER_ID, 200L, "key-m", 1, "당일 봉사 미이행")) + .willReturn(new VolunteerResponse(200L, "학생", 2105, 301, 3, null, DutyStatus.MISSED)); + + mockMvc.perform(post(BASE + "/200/duty/missed").header("Idempotency-Key", "key-m") + .contentType(MediaType.APPLICATION_JSON).content(MISSED_BODY).with(loginAs(MEMBER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.todayDuty").value("MISSED")) + .andExpect(jsonPath("$.volunteerCount").value(3)); + } + + @Test + @DisplayName("날짜별 미이행은 경로의 운영일·학생 id로 처리하고 그날의 기록을 응답한다") + void markMissedPassesDateAndStudent() throws Exception { + given(volunteerService.markMissed(MEMBER_ID, 200L, LocalDate.of(2026, 10, 14), null, 1, "당일 봉사 미이행")) + .willReturn(new VolunteerDutyRecordResponse(200L, 2105, "학생", 301, DutyStatus.MISSED, true, 1, "당일 봉사 미이행")); + + mockMvc.perform(patch(BASE + "/duties/2026-10-14/200/missed") + .contentType(MediaType.APPLICATION_JSON).content(MISSED_BODY).with(loginAs(MEMBER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.adjusted").value(true)) + .andExpect(jsonPath("$.addedCount").value(1)); + } + + @ParameterizedTest + @ValueSource(strings = {"{}", "{\"delta\":100}", "{\"delta\":-100}"}) + @DisplayName("미이행 횟수가 없거나 -99~99를 벗어나면 400이고 서비스를 호출하지 않는다") + void missedRejectsInvalidDelta(String body) throws Exception { + mockMvc.perform(post(BASE + "/200/duty/missed") + .contentType(MediaType.APPLICATION_JSON).content(body).with(loginAs(MEMBER_ID))) + .andExpect(status().isBadRequest()); + mockMvc.perform(patch(BASE + "/duties/2026-10-14/200/missed") + .contentType(MediaType.APPLICATION_JSON).content(body).with(loginAs(MEMBER_ID))) + .andExpect(status().isBadRequest()); + + verify(volunteerService, never()).markTodayMissed(any(), any(), any(), anyInt(), any()); + verify(volunteerService, never()).markMissed(any(), any(), any(), any(), anyInt(), any()); + } + + @Test + @DisplayName("횟수를 이미 처리한 미이행은 409 DUTY_ALREADY_ADJUSTED로 응답한다") + void markMissedTwiceReturnsConflict() throws Exception { + willThrow(new CustomException(ErrorCode.DUTY_ALREADY_ADJUSTED)).given(volunteerService) + .markMissed(MEMBER_ID, 200L, LocalDate.of(2026, 10, 14), null, 1, "당일 봉사 미이행"); + + mockMvc.perform(patch(BASE + "/duties/2026-10-14/200/missed") + .contentType(MediaType.APPLICATION_JSON).content(MISSED_BODY).with(loginAs(MEMBER_ID))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUTY_ALREADY_ADJUSTED")); } private static RequestPostProcessor loginAs(Long memberId) { From 699c7f84439f3a051f14da8ae2e0e9afc82e9b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:59:32 +0900 Subject: [PATCH 13/14] =?UTF-8?q?chore:=20=ED=95=98=EB=84=A4=EC=8A=A4=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs-harness | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-harness b/docs-harness index 65640ca4..23a009eb 160000 --- a/docs-harness +++ b/docs-harness @@ -1 +1 @@ -Subproject commit 65640ca4351c2d721582d57c1939a02a86c9c062 +Subproject commit 23a009eb3e24aa52d3310594826f4975fd53c831 From d6ca6eb46c1ef484b9846a213a9da3c8a2ba61df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=B0=AC?= <265934944+kimseongchan0914@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:06:35 +0900 Subject: [PATCH 14/14] =?UTF-8?q?chore:=20=ED=95=98=EB=84=A4=EC=8A=A4=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs-harness | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-harness b/docs-harness index 23a009eb..ef805ce0 160000 --- a/docs-harness +++ b/docs-harness @@ -1 +1 @@ -Subproject commit 23a009eb3e24aa52d3310594826f4975fd53c831 +Subproject commit ef805ce07abed711d33c33bb2f4b0f3533648fdf