Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f88841e
perf: 출석 대상 학생의 얼굴 템플릿만 읽는 쿼리 추가
minwoo-3 Oct 12, 2026
e8767f1
perf: 얼굴 인식 후보를 쿼리에서 출석 대상으로 거르기
minwoo-3 Oct 12, 2026
5847e97
refactor: 쓰지 않는 얼굴 템플릿 전체 조회 메서드 삭제
minwoo-3 Oct 12, 2026
6359ecc
test: 출석 대상 얼굴 템플릿 쿼리 테스트 추가
minwoo-3 Oct 12, 2026
27fff5c
test: 출석 대상 템플릿이 없을 때 세션 생성 거부 테스트 추가
minwoo-3 Oct 12, 2026
6d8ed1d
Merge pull request #248 from Start-Up-10th/perf/face-eligible-templates
minwoo-3 Oct 13, 2026
d295486
feat: 당일 봉사 미이행 상태와 지정에 연결한 조정 기록 추가
kimseongchan0914 Oct 15, 2026
38b6d27
feat: 미이행 처리와 날짜별·월별 당일 봉사 조회 쿼리 추가
kimseongchan0914 Oct 15, 2026
e67de21
feat: 미이행 처리 요청·날짜별 기록 응답과 오류 코드 추가
kimseongchan0914 Oct 15, 2026
d801fe2
feat: 당일 봉사 미이행 처리와 날짜별 기록·월 요약 서비스 추가
kimseongchan0914 Oct 15, 2026
6afbc93
test: 당일 봉사 미이행 처리와 날짜별 기록·월 요약 서비스 테스트 추가
kimseongchan0914 Oct 15, 2026
1f396dc
feat: 당일 봉사 미이행 처리와 날짜별 기록·월 요약 API 추가
kimseongchan0914 Oct 15, 2026
b7e30fb
test: 당일 봉사 미이행·날짜별 기록·월 요약 API 테스트 추가
kimseongchan0914 Oct 15, 2026
699c7f8
chore: 하네스 최신화
kimseongchan0914 Oct 15, 2026
d6ca6eb
chore: 하네스 최신화
kimseongchan0914 Oct 15, 2026
688d007
Merge pull request #251 from Start-Up-10th/feat/volunteer-duty-missed
kimseongchan0914 Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,11 +21,30 @@ public interface FaceTemplateRepository extends JpaRepository<FaceTemplate, Long
@Query("SELECT template FROM FaceTemplate template WHERE template.student.id = :studentId")
Optional<FaceTemplate> findForUpdateByStudentId(@Param("studentId") Long studentId);

@EntityGraph(attributePaths = "student")
List<FaceTemplate> findAllByOrderByStudent_IdAsc();

@EntityGraph(attributePaths = "student")
List<FaceTemplate> findAllByStudent_IdIn(Collection<Long> 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<FaceTemplate> 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<FaceTemplate> findAllEligibleByStudentIdIn(@Param("studentIds") Collection<Long> studentIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,7 @@ private static Long canonicalStudentId(String value) {
}

private List<FaceTemplate> eligibleTemplates() {
return faceTemplateRepository.findAllByOrderByStudent_IdAsc().stream()
.filter(template -> eligibleStudent(template.getStudent()))
.toList();
return faceTemplateRepository.findAllEligible();
}

private AiFaceSessionRequest sessionRequest(List<FaceTemplate> templates) {
Expand All @@ -451,11 +449,6 @@ private AiFaceSessionRequest sessionRequest(List<FaceTemplate> 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<Long> studentIds) {
return candidateRequestSnapshot(studentIds, null).request();
}
Expand All @@ -475,9 +468,7 @@ private Map<Long, Long> candidateTemplateRevisions(Set<Long> studentIds) {
}

private List<FaceTemplate> candidateTemplates(Set<Long> studentIds) {
List<FaceTemplate> templates = faceTemplateRepository.findAllByStudent_IdIn(studentIds).stream()
.filter(template -> eligibleStudent(template.getStudent()))
.toList();
List<FaceTemplate> templates = faceTemplateRepository.findAllEligibleByStudentIdIn(studentIds);
if (templates.size() != studentIds.size() || templates.size() > properties.maxCandidates() || templates.isEmpty()) {
throw new CustomException(ErrorCode.FACE_SESSION_NOT_FOUND);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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<VolunteerDutyRecordResponse> 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<LocalDate, Long> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
) {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,10 @@ public enum DutyStatus {
/** 사감이 오늘 봉사자로 지정했다. 아직 봉사 완료를 확인하지 않았다. */
ASSIGNED,
/** 자치위원이 봉사 완료를 확인했다. 이때 봉사 횟수를 1 줄였다. */
COMPLETED
COMPLETED,
/**
* 봉사를 하지 않았다(#250). 관리자가 미이행으로 처리했거나, 운영일이 지나도록 완료·미이행 처리가 없던 지정이다.
* 뒤의 경우는 저장값이 {@code ASSIGNED}이고 조회할 때만 미이행으로 본다.
*/
MISSED
}
Loading
Loading