diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java index f94ee34c..73d54cff 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java @@ -41,9 +41,10 @@ ApiResponse> getRequests( @Operation(summary = "학생회비 납부 상태 수정", description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. " + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. " - + "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다.") + + "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다. " + + "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다.") @ApiErrorCode(type = FeeErrorCode.class, - codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND"}) + codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_NOT_LATEST"}) ApiResponse updateStatus( AdminApiUser apiUser, Long studentTransferStatusId, diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java index c68ed261..82b59488 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java @@ -34,7 +34,8 @@ public interface AppFeeApi { @Operation(summary = "학생회비 납부 요청", description = "토스로 송금을 마친 학생이 남은 학기 수와 학점이수현황 캡처를 함께 보내 운영진의 확인을 요청한다. " + "요청 시점의 금액(남은 학기 수 × 학기당 회비)을 함께 남긴다. " - + "처음 내는 학생과 확인필요로 돌려받은 학생만 요청할 수 있고, 돌려받은 학생은 이전 요청을 덮어쓴다.") + + "처음 내는 학생과 확인필요로 돌려받은 학생만 요청할 수 있다. 돌려받은 학생이 다시 내면 새 요청으로 쌓이고 " + + "이전 요청은 그대로 남는다.") @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_REMAINING_SEMESTERS", "INVALID_PROOF_IMAGE", "FEE_ACCOUNT_NOT_FOUND", "FEE_REQUEST_ALREADY_REQUESTED"}) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java index 80ea794b..f651418f 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java @@ -36,7 +36,7 @@ public ApiResponse getFeeByMemberId( : FeeAmountCalculator.validateRemainingSemesters(remainingSemesters); FeeAccount account = feeAccountProvider.getAccount(); - FeeResponse response = studentTransferStatusService.findByMemberId(apiUser.userId()) + FeeResponse response = studentTransferStatusService.findLatestByMemberId(apiUser.userId()) .map(transferStatus -> FeeResponse.of(transferStatus, account, semesters)) .orElseGet(() -> FeeResponse.notRequested(account, semesters)); return ApiResponse.success(response); diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java index dcfeff74..e0351c6a 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java @@ -14,24 +14,21 @@ import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; /** * 학생회비 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 한 번에 받는다. * {@code welfare}(납부 요청)와 {@code file}(인증 이미지) 두 도메인을 조합한다. *

- * 메서드에 {@code @Transactional}을 붙이지 않는다. 저장은 {@code StudentTransferStatusService.request}가 - * 자기 트랜잭션에서 끝내고, 이전 이미지 삭제는 R2 호출이라 롤백되지 않아 그 커밋 뒤에 해야 하기 때문이다 - * ({@code architecture.md} 6-1절 "롤백 불가한 외부 부수효과"). + * 메서드에 {@code @Transactional}을 붙이지 않는다. 쓰기는 {@code StudentTransferStatusService.request} 하나뿐이고 + * 그 트랜잭션 안에서 끝난다. 인증 이미지는 조회해서 검증만 한다. + *

+ * 다시 내도 이전 인증 이미지를 지우지 않는다. 반려된 이전 요청이 이력으로 남아 그 이미지를 계속 가리킨다. */ @Component @RequiredArgsConstructor public class FeeSubmitUseCase { - private static final Logger log = LoggerFactory.getLogger(FeeSubmitUseCase.class); - private final StudentTransferStatusService studentTransferStatusService; private final FeeAccountProvider feeAccountProvider; private final FileService fileService; @@ -44,17 +41,8 @@ public FeeResponse submit(Long memberId, FeeSubmitRequest request) { FeeAccount account = feeAccountProvider.getAccount(); long amount = account.calculateTransferAmount(remainingSemesters); - // 반려 후 다시 내면 이전 요청을 덮어쓰므로, 교체될 이전 키를 알려고 한 번 더 읽는다. request가 돌려주게 하면 - // 반환 타입에 값을 하나 더 얹어야 해서, 유니크 인덱스 조회 한 번을 받아들였다. 같은 학생의 동시 요청이 겹치면 - // 낡은 키를 볼 수 있는데, 그때 생기는 건 고아 파일 하나뿐이라 파일 정리 배치가 걷어간다. - String previousProofImageKey = studentTransferStatusService.findByMemberId(memberId) - .map(StudentTransferStatus::getProofImageKey) - .orElse(null); StudentTransferStatus saved = studentTransferStatusService.request(memberId, remainingSemesters, request.proofImageKey(), amount); - - deletePreviousProofImage(previousProofImageKey, saved.getProofImageKey()); - return FeeResponse.of(saved, account, null); } @@ -73,21 +61,4 @@ private void validateProofImage(String proofImageKey, Long memberId) { throw new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE); } } - - /** - * 다시 내면서 교체된 이전 이미지를 지운다. 아무도 가리키지 않게 된 데다 학번·이름이 찍혀 있어 남겨둘 이유가 없다. - *

- * 삭제가 실패해도 요청은 성공으로 둔다 — 고아 파일 하나가 남는 편이 요청 실패보다 낫다. - * 남은 것은 파일 정리 배치가 걷어간다. - */ - private void deletePreviousProofImage(String previousKey, String currentKey) { - if (previousKey == null || previousKey.equals(currentKey)) { - return; - } - try { - fileService.findByFileKey(previousKey).ifPresent(file -> fileService.delete(file.getId())); - } catch (RuntimeException e) { - log.warn("이전 인증 이미지를 지우지 못했습니다. fileKey={}", previousKey, e); - } - } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java index d1afd9c4..4dd64f3e 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java @@ -22,6 +22,7 @@ public enum FeeErrorCode implements ErrorCode { FEE_ACCOUNT_NOT_FOUND(ErrorStatus.NOT_FOUND, "학생회비 납부 계좌가 등록되지 않았습니다."), FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "제출된 납부 정보가 없습니다."), FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."), + FEE_REQUEST_NOT_LATEST(ErrorStatus.CONFLICT, "회원의 최신 납부 요청만 처리할 수 있습니다."), MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다."); private final int status; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java index 60c6b4ca..213d7147 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java @@ -28,8 +28,8 @@ public class StudentTransferStatus { private LocalDateTime updatedAt; /** - * 송금을 마친 학생의 첫 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 - * 한 번에 받으므로 바로 납부확인중이 된다. + * 송금을 마친 학생의 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 한 번에 받으므로 + * 바로 납부확인중이 된다. 처음 내든 반려 뒤 다시 내든 늘 새 요청(행)이고, 이전 요청은 이력으로 남는다. */ public static StudentTransferStatus request( Long memberId, @@ -60,23 +60,13 @@ public static StudentTransferStatus of( } /** - * 반려된 학생이 학기 인증부터 다시 납부한다. 이전 요청의 입력값과 처리 흔적(안내 문구·처리 시각)을 - * 지우고 새 요청으로 만든다. - *

- * 반려에서만 가능하다. 납부확인중이면 이미 운영진에게 요청이 가 있고, 납부완료면 더 낼 것이 없다. + * 이 요청 다음에 새 납부 요청을 받을 수 있는지 본다. 반려된 요청 뒤에만 다시 낼 수 있다 — + * 납부확인중이면 이미 운영진에게 요청이 가 있고, 납부완료면 더 낼 것이 없다. */ - public void resubmit(int remainingSemesters, String proofImageKey, long amount, LocalDateTime now) { + public void validateRequestable() { if (status != TransferStatus.REJECTED) { throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED); } - - this.status = TransferStatus.PENDING; - this.remainingSemesters = remainingSemesters; - this.proofImageKey = proofImageKey; - this.amount = amount; - this.adminNote = null; - this.requestedAt = now; - this.reviewedAt = null; } /** diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java index 6264a79f..87adf5c5 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java @@ -7,12 +7,13 @@ import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; +// 납부 요청은 이력으로 쌓이므로 회원 기준 조회는 모두 최신 요청(PK가 가장 큰 행) 하나만 돌려준다 public interface StudentTransferStatusRepository { - Optional findByMemberId(Long memberId); + Optional findLatestByMemberId(Long memberId); Optional findById(Long id); - List findAllByMemberIds(List memberIds); + List findLatestByMemberIds(List memberIds); - // memberIds가 null이면 전체 대상, 빈 리스트면 결과 없음을 의도한 것이므로 호출 전에 걸러낸다(빈 리스트로 호출하지 않는다) + // 회원별 최신 요청만 대상으로 한다. memberIds가 null이면 전체 대상, 빈 리스트면 결과 없음을 의도한 것이므로 호출 전에 걸러낸다(빈 리스트로 호출하지 않는다) PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); StudentTransferStatus save(StudentTransferStatus transferStatus); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java index dbe830b0..a9671aaa 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java @@ -10,23 +10,26 @@ public interface StudentTransferStatusService { - /** 회원의 납부 요청. 한 번도 요청하지 않은 회원(미납부)이면 비어 있다. */ - Optional findByMemberId(Long memberId); + /** 회원의 최신 납부 요청 — 곧 회원의 지금 상태다. 한 번도 요청하지 않은 회원(미납부)이면 비어 있다. */ + Optional findLatestByMemberId(Long memberId); /** - * 학기 인증과 송금 완료를 한 번에 받아 납부확인중으로 만든다. 처음이면 새로 만들고, 반려된 요청이면 다시 낸다. - * 납부확인중·납부완료면 {@code FEE_REQUEST_ALREADY_REQUESTED}다. + * 학기 인증과 송금 완료를 한 번에 받아 납부확인중인 새 요청을 만든다. 처음이든 반려 뒤든 늘 새 행이고, + * 이전 요청은 그대로 남는다. 최신 요청이 납부확인중·납부완료면 {@code FEE_REQUEST_ALREADY_REQUESTED}다. * * @param amount 요청 시점에 안내한 납부 금액 */ StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount); - /** 회원별 납부 상태. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 한 번도 요청하지 않은 회원은 빠진다. */ + /** 회원별 최신 납부 요청. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 한 번도 요청하지 않은 회원은 빠진다. */ Map getMapByMemberIds(List memberIds); // status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음 PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); - /** 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다. */ + /** + * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다. + * 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다. + */ StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java index ff961934..615f52fa 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java @@ -27,8 +27,8 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService { // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다). @Override - public Optional findByMemberId(Long memberId) { - return studentTransferStatusRepository.findByMemberId(memberId); + public Optional findLatestByMemberId(Long memberId) { + return studentTransferStatusRepository.findLatestByMemberId(memberId); } @Override @@ -36,20 +36,20 @@ public Map getMapByMemberIds(List memberIds) if (memberIds.isEmpty()) { return Map.of(); } - return studentTransferStatusRepository.findAllByMemberIds(memberIds).stream() + return studentTransferStatusRepository.findLatestByMemberIds(memberIds).stream() .collect(Collectors.toMap(StudentTransferStatus::getMemberId, Function.identity())); } @Override @Transactional public StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount) { - LocalDateTime now = LocalDateTime.now(DateUtil.KST); - StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId) - .map(existing -> { - existing.resubmit(remainingSemesters, proofImageKey, amount, now); - return existing; - }) - .orElseGet(() -> StudentTransferStatus.request(memberId, remainingSemesters, proofImageKey, amount, now)); + // 반려된 요청 뒤에만 새 요청을 받는다. 이전 요청은 고치지 않고 이력으로 남긴다 + studentTransferStatusRepository.findLatestByMemberId(memberId) + .ifPresent(StudentTransferStatus::validateRequestable); + + // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V26)가 두 번째 저장을 막는다 + StudentTransferStatus transferStatus = StudentTransferStatus.request( + memberId, remainingSemesters, proofImageKey, amount, LocalDateTime.now(DateUtil.KST)); return studentTransferStatusRepository.save(transferStatus); } @@ -66,8 +66,20 @@ public PageResult search(TransferStatus status, List new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND)); + requireLatest(transferStatus); transferStatus.review(status, adminNote, LocalDateTime.now(DateUtil.KST)); return studentTransferStatusRepository.save(transferStatus); } + + // 지난 요청은 이력이라 바꾸지 않는다. 회원의 지금 상태는 최신 요청 하나뿐이고, 지난 요청을 되살리면 + // 회원당 진행 중 요청이 둘이 될 수 있다 + private void requireLatest(StudentTransferStatus transferStatus) { + boolean latest = studentTransferStatusRepository.findLatestByMemberId(transferStatus.getMemberId()) + .map(found -> found.getId().equals(transferStatus.getId())) + .orElse(false); + if (!latest) { + throw new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_LATEST); + } + } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java index 99a9b0e1..b6772a83 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java @@ -7,8 +7,8 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import java.time.LocalDateTime; import kr.ac.kookmin.stream.db.common.BaseTimeEntity; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; @@ -20,10 +20,12 @@ * 현재는 학생회장/총무부장 등 소수 인원만 처리 권한을 가져 낙관적 락(@Version)을 쓰지 않는다. * 처리 권한자가 여러 명으로 늘어나면 동시 처리 경합을 막기 위해 @Version 도입을 검토한다. */ +// uk_student_transfer_statuses_active_member_id(V26)는 generated column active_member_id에 걸려 있다. +// Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. @Entity @Table( name = "student_transfer_statuses", - uniqueConstraints = @UniqueConstraint(name = "uk_student_transfer_statuses_member_id", columnNames = "member_id") + indexes = @Index(name = "idx_student_transfer_statuses_member_id_id", columnList = "member_id, student_transfer_status_id") ) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class StudentTransferStatusJpaEntity extends BaseTimeEntity { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java index e5e492ba..dc19c3cd 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java @@ -9,21 +9,33 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +/** + * 납부 요청은 이력으로 쌓인다. 회원의 지금 상태는 PK가 가장 큰 행이다 — 같은 회원에게 반려(REJECTED)가 여러 개 + * 쌓이고 최신 행 자체가 반려일 수도 있어 상태로는 고를 수 없고, requested_at은 초 단위라 같은 초에 겹칠 수 있다. + */ public interface StudentTransferStatusJpaRepository extends JpaRepository { - Optional findByMemberId(Long memberId); + Optional findFirstByMemberIdOrderByIdDesc(Long memberId); - List findAllByMemberIdIn(List memberIds); + @Query(""" + SELECT r FROM StudentTransferStatusJpaEntity r + WHERE r.memberId IN :memberIds + AND r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId) + """) + List findLatestByMemberIdIn(@Param("memberIds") List memberIds); + // 회원별 최신 요청만 대상으로 한다. 상태 필터도 최신 요청의 상태로 건다 @Query(value = """ SELECT r FROM StudentTransferStatusJpaEntity r - WHERE (:status IS NULL OR r.status = :status) + WHERE r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId) + AND (:status IS NULL OR r.status = :status) AND (:filterByMember = false OR r.memberId IN :memberIds) ORDER BY r.createdAt DESC, r.id DESC """, countQuery = """ SELECT COUNT(r) FROM StudentTransferStatusJpaEntity r - WHERE (:status IS NULL OR r.status = :status) + WHERE r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId) + AND (:status IS NULL OR r.status = :status) AND (:filterByMember = false OR r.memberId IN :memberIds) """) Page search( diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java index 1d8e325b..52051f76 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java @@ -2,12 +2,16 @@ import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.repository.StudentTransferStatusRepository; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @@ -16,16 +20,19 @@ @RequiredArgsConstructor public class StudentTransferStatusRepositoryImpl implements StudentTransferStatusRepository { + private static final String ACTIVE_REQUEST_UNIQUE_CONSTRAINT = "uk_student_transfer_statuses_active_member_id"; + private final StudentTransferStatusJpaRepository studentTransferStatusJpaRepository; @Override - public Optional findByMemberId(Long memberId) { - return studentTransferStatusJpaRepository.findByMemberId(memberId).map(StudentTransferStatusJpaEntity::toDomain); + public Optional findLatestByMemberId(Long memberId) { + return studentTransferStatusJpaRepository.findFirstByMemberIdOrderByIdDesc(memberId) + .map(StudentTransferStatusJpaEntity::toDomain); } @Override - public List findAllByMemberIds(List memberIds) { - return studentTransferStatusJpaRepository.findAllByMemberIdIn(memberIds).stream() + public List findLatestByMemberIds(List memberIds) { + return studentTransferStatusJpaRepository.findLatestByMemberIdIn(memberIds).stream() .map(StudentTransferStatusJpaEntity::toDomain) .toList(); } @@ -54,6 +61,16 @@ public PageResult search(TransferStatus status, List