From f63adc17033c2df8ed5123459dcd90e842c36c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:30:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=9A=8C=EB=B9=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B8=B0=EC=A4=80=20=ED=9A=8C=EC=9B=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=99=80=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 납부자 목록이 납부 전 회원까지 보여줘야 해서 회원을 회비 상태로 거르는 페이지 조회를 넣었다. 목록의 Stream 가입 상태는 회원마다 약관을 조회하면 쿼리가 페이지 크기만큼 늘어나므로 한 번에 가져와 짝짓는다. 관리자 처리는 이미 처리한 요청도 다시 바꿀 수 있게 풀었다. 잘못 처리한 경우를 되돌리기 위해서이고, 그래서 납부완료도 더는 종결 상태가 아니다. 안내 문구는 확인필요일 때만 남기고, 납부확인중으로 되돌리면 처리 시각도 지운다. --- .../member/repository/MemberRepository.java | 5 ++++ .../MemberTermAgreementRepository.java | 1 + .../domain/member/service/MemberService.java | 9 +++++++ .../member/service/MemberTermService.java | 5 ++++ .../service/impl/MemberServiceImpl.java | 22 ++++++++++++++++ .../service/impl/MemberTermServiceImpl.java | 22 ++++++++++++++++ .../welfare/domain/fee/domain/FeeBanks.java | 26 +++++++++++++++++++ .../domain/fee/domain/FeeErrorCode.java | 5 +++- .../fee/domain/StudentTransferStatus.java | 11 ++++++-- .../service/StudentTransferStatusService.java | 3 ++- .../StudentTransferStatusServiceImpl.java | 11 ++------ .../stream/db/member/MemberJpaRepository.java | 19 ++++++++++++++ .../db/member/MemberRepositoryImpl.java | 20 ++++++++++++++ .../MemberTermAgreementJpaRepository.java | 2 ++ .../MemberTermAgreementRepositoryImpl.java | 7 +++++ 15 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java index bd0465e3..cae0aeaa 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java @@ -2,6 +2,8 @@ import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.member.domain.member.domain.Member; public interface MemberRepository { @@ -10,6 +12,9 @@ public interface MemberRepository { boolean existsByPhoneNumber(String phoneNumber); List findAllByIds(List ids); List searchIdsByKeyword(String keyword); + + // feeStatus·keyword가 null이면 그 조건은 빼고 전체를 대상으로 한다 + PageResult searchByFeeStatus(String feeStatus, String keyword, PageOffset pageOffset); Member save(Member member); void clearFcmTokenOfOthers(Long memberId, String fcmToken); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java index 86db290b..83820f9f 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java @@ -5,5 +5,6 @@ public interface MemberTermAgreementRepository { List findAllByMemberId(Long memberId); + List findAllByMemberIdIn(List memberIds); void saveAll(List agreements); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java index 5defbcdb..d2c05f12 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java @@ -2,6 +2,9 @@ import java.util.List; import java.util.Map; +import java.util.Set; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand; import kr.ac.kookmin.stream.member.domain.member.domain.MemberSignUpCommand; @@ -17,6 +20,12 @@ public interface MemberService { List findAllByIds(List ids); Map getMapByIds(List ids); List searchIdsByKeyword(String keyword); + + /** 회비 상태와 검색어로 회원을 페이지 단위로 조회한다. 관리자 납부자 목록이 회원 기준이라 필요하다. */ + PageResult searchByFeeStatus(String feeStatus, String keyword, PageOffset pageOffset); + + /** 회원가입(전화번호 + 필수 약관 동의)을 마친 회원의 id. 목록의 Stream 가입 상태에 쓴다. */ + Set findSignedUpMemberIds(List memberIds); Member updateProfile(Long id, MemberProfileCommand command); Member registerOrUpdateByStudentId(MemberProfileCommand command); Member signUp(Long memberId, MemberSignUpCommand command); diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java index 49f847d4..317597fc 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java @@ -1,9 +1,14 @@ package kr.ac.kookmin.stream.member.domain.member.service; +import java.util.List; import java.util.Map; +import java.util.Set; import kr.ac.kookmin.stream.member.domain.member.domain.TermType; public interface MemberTermService { boolean hasAgreedRequiredTerms(Long memberId); + + /** 필수 약관에 모두 동의한 회원의 id. 목록에서 회원마다 조회하지 않으려고 한 번에 가져온다. */ + Set findMemberIdsAgreedRequiredTerms(List memberIds); void agree(Long memberId, Map termAgreements); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java index 9ec85614..5dc72731 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java @@ -2,6 +2,9 @@ import java.util.List; import java.util.Map; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.common.PageOffset; +import java.util.Set; import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; @@ -103,6 +106,25 @@ public void updateFcmToken(Long memberId, String fcmToken) { } } + @Override + public PageResult searchByFeeStatus(String feeStatus, String keyword, PageOffset pageOffset) { + return memberRepository.searchByFeeStatus(feeStatus, keyword, pageOffset); + } + + // 회원마다 약관을 조회하면 목록 한 장에 쿼리가 페이지 크기만큼 늘어나므로 한 번에 가져와 짝짓는다 + @Override + public Set findSignedUpMemberIds(List memberIds) { + if (memberIds.isEmpty()) { + return Set.of(); + } + Set agreedMemberIds = memberTermService.findMemberIdsAgreedRequiredTerms(memberIds); + + return memberRepository.findAllByIds(memberIds).stream() + .filter(member -> member.hasPhoneNumber() && agreedMemberIds.contains(member.getId())) + .map(Member::getId) + .collect(Collectors.toSet()); + } + // 전화번호와 필수 약관 동의가 모두 있어야 가입을 마친 것으로 본다 private boolean isSignedUp(Member member) { return member.hasPhoneNumber() && memberTermService.hasAgreedRequiredTerms(member.getId()); diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java index 40a35149..0631ca94 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java @@ -23,6 +23,28 @@ class MemberTermServiceImpl implements MemberTermService { private final MemberTermAgreementRepository memberTermAgreementRepository; + @Override + public Set findMemberIdsAgreedRequiredTerms(List memberIds) { + if (memberIds.isEmpty()) { + return Set.of(); + } + + Map> agreedByMemberId = memberTermAgreementRepository.findAllByMemberIdIn(memberIds).stream() + .filter(MemberTermAgreement::isAgreed) + .collect(Collectors.groupingBy( + MemberTermAgreement::getMemberId, + Collectors.mapping(MemberTermAgreement::getTermType, Collectors.toSet()))); + + Set requiredTermTypes = Arrays.stream(TermType.values()) + .filter(TermType::required) + .collect(Collectors.toSet()); + + return agreedByMemberId.entrySet().stream() + .filter(entry -> entry.getValue().containsAll(requiredTermTypes)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + @Override public boolean hasAgreedRequiredTerms(Long memberId) { Set agreedTermTypes = memberTermAgreementRepository.findAllByMemberId(memberId).stream() diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java new file mode 100644 index 00000000..9f8aad94 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.welfare.domain.fee.domain; + +import java.util.Set; +import kr.ac.kookmin.stream.common.BusinessException; + +/** + * 회비 납부 계좌로 쓸 수 있는 은행. 관리자 화면의 은행 선택 목록과 같은 22개다. + *

+ * enum이 아니라 문자열 집합인 이유는, 이 값이 토스 송금 링크의 {@code bank} 파라미터로 그대로 나가고 + * 화면에도 그대로 표시되기 때문이다. 중간에 코드로 바꿨다가 되돌리면 한쪽만 고쳤을 때 조용히 어긋난다. + */ +public final class FeeBanks { + + private static final Set ALLOWED = Set.of( + "경남", "광주", "기업", "국민", "대구", "부산", "산림", "새마을", "SC제일", "신한", "신협", + "수협", "케이뱅크", "우리", "우체국", "저축은행", "전북", "제주", "카카오뱅크", "토스뱅크", "하나", "농협" + ); + + private FeeBanks() {} + + public static void validate(String bank) { + if (bank == null || !ALLOWED.contains(bank)) { + throw new BusinessException(FeeErrorCode.INVALID_BANK); + } + } +} 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 d3f0ab5e..763ce85a 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 @@ -15,10 +15,13 @@ public enum FeeErrorCode implements ErrorCode { INVALID_TRANSFER_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 납부 상태입니다."), INVALID_REMAINING_SEMESTERS(ErrorStatus.BAD_REQUEST, "유효하지 않은 남은 학기 수입니다."), INVALID_PROOF_IMAGE(ErrorStatus.BAD_REQUEST, "유효하지 않은 인증 이미지입니다."), + INVALID_BANK(ErrorStatus.BAD_REQUEST, "지원하지 않는 은행입니다."), + INVALID_ACCOUNT_NO(ErrorStatus.BAD_REQUEST, "유효하지 않은 계좌번호입니다."), + INVALID_TRANSFER_AMOUNT(ErrorStatus.BAD_REQUEST, "유효하지 않은 학생회비 금액입니다."), + INVALID_ADMIN_NOTE(ErrorStatus.BAD_REQUEST, "유효하지 않은 관리자 안내 문구입니다."), FEE_ACCOUNT_NOT_FOUND(ErrorStatus.NOT_FOUND, "학생회비 납부 계좌가 등록되지 않았습니다."), FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "제출된 납부 정보가 없습니다."), FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."), - FEE_REQUEST_ALREADY_REVIEWED(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 66a4c858..12f35814 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 @@ -80,9 +80,16 @@ public void requestConfirmation(LocalDateTime now) { this.reviewedAt = null; } - public void review(TransferStatus reviewedStatus, LocalDateTime now) { + /** + * 관리자가 납부 상태를 바꾼다. 이미 처리한 요청도 다시 바꿀 수 있다 — 잘못 처리한 경우를 되돌리기 위해서다. + * 그래서 납부완료도 종결 상태가 아니다. + *

+ * 안내 문구는 확인필요일 때만 남긴다. 납부확인중으로 되돌리면 아직 처리 전이라는 뜻이므로 처리 시각도 지운다. + */ + public void review(TransferStatus reviewedStatus, String adminNote, LocalDateTime now) { this.status = reviewedStatus; - this.reviewedAt = now; + this.adminNote = reviewedStatus == TransferStatus.NEEDS_CHECK ? adminNote : null; + this.reviewedAt = reviewedStatus == TransferStatus.PENDING ? null : now; } private void requireUnpaid() { 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 ab49881a..4dedd902 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 @@ -22,5 +22,6 @@ public interface StudentTransferStatusService { // status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음 PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); - StudentTransferStatus review(Long transferStatusId, TransferStatus status); + /** 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다. */ + 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 37a2df49..8eb68282 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 @@ -58,18 +58,11 @@ public PageResult search(TransferStatus status, List new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND)); - if (transferStatus.getStatus() != TransferStatus.PENDING) { - throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REVIEWED); - } - transferStatus.review(status, LocalDateTime.now()); + transferStatus.review(status, adminNote, LocalDateTime.now(DateUtil.KST)); return studentTransferStatusRepository.save(transferStatus); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java index 0fd828a2..d7515b66 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java @@ -2,6 +2,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -24,6 +26,23 @@ public interface MemberJpaRepository extends JpaRepository searchIdsByKeyword(@Param("keyword") String keyword); + // 관리자 납부자 목록은 납부 정보를 내지 않은 회원도 보여줘야 해서 회원 기준으로 조회한다. + // feeStatus 필터는 idx_members_fee_status_is_deleted(V20)를 탄다. + @Query(""" + SELECT m FROM MemberJpaEntity m + WHERE m.isDeleted = false + AND (:feeStatus IS NULL OR m.feeStatus = :feeStatus) + AND (:keyword IS NULL + OR m.name LIKE CONCAT('%', :keyword, '%') + OR m.studentId LIKE CONCAT('%', :keyword, '%')) + ORDER BY m.id DESC + """) + Page searchByFeeStatus( + @Param("feeStatus") String feeStatus, + @Param("keyword") String keyword, + Pageable pageable + ); + // 탈퇴 회원에게는 어차피 보내지 않으므로 is_deleted와 관계없이 지운다 @Modifying(clearAutomatically = true) @Query(""" diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java index 0dba47a5..cbd63877 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java @@ -9,6 +9,10 @@ import kr.ac.kookmin.stream.member.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @Repository @@ -44,6 +48,22 @@ public List findAllByIds(List ids) { .toList(); } + @Override + public PageResult searchByFeeStatus(String feeStatus, String keyword, PageOffset pageOffset) { + Page result = memberJpaRepository.searchByFeeStatus( + feeStatus, + keyword == null || keyword.isBlank() ? null : keyword, + PageRequest.of(pageOffset.page(), pageOffset.size()) + ); + + return PageResult.of( + result.getContent().stream().map(MemberJpaEntity::toDomain).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements() + ); + } + @Override public List searchIdsByKeyword(String keyword) { return memberJpaRepository.searchIdsByKeyword(keyword); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java index 35637f1f..0595a9a9 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java @@ -5,4 +5,6 @@ public interface MemberTermAgreementJpaRepository extends JpaRepository { List findAllByMemberId(Long memberId); + + List findAllByMemberIdIn(List memberIds); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java index d65d7280..19a87920 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java @@ -12,6 +12,13 @@ public class MemberTermAgreementRepositoryImpl implements MemberTermAgreementRep private final MemberTermAgreementJpaRepository memberTermAgreementJpaRepository; + @Override + public List findAllByMemberIdIn(List memberIds) { + return memberTermAgreementJpaRepository.findAllByMemberIdIn(memberIds).stream() + .map(MemberTermAgreementJpaEntity::toDomain) + .toList(); + } + @Override public List findAllByMemberId(Long memberId) { return memberTermAgreementJpaRepository.findAllByMemberId(memberId).stream() From 1347c71962262fdf14c6feabde9ea4a35bdfe622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:34:10 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=95=99=EC=83=9D=ED=9A=8C?= =?UTF-8?q?=EB=B9=84=20=EB=82=A9=EB=B6=80=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?API=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 납부자 목록을 회원 기준으로 바꿨다. 납부 정보를 내지 않은 회원은 welfare 쪽에 행이 없어 기존 조회로는 "납부 전" 학생이 통째로 빠졌다. 상태 필터도 같은 이유로 회원 쪽에 건다. 계좌와 학기당 회비를 한 엔드포인트로 합치고 경로를 /link에서 /account로 바꿨다. 이 API가 다루는 건 설정이고 토스 링크는 그걸로 만들어지는 결과물이라 결과물 이름을 경로에 쓰지 않는다. 조회도 같은 경로로 신설했다. 상태 처리에 안내 문구를 넣고, 이미 처리한 요청도 다시 바꿀 수 있게 했다. 처리 결과는 납부 상태와 회원 쪽 사본, 납부자 명부 셋이 어긋나지 않도록 한 트랜잭션으로 묶는다. --- .../api/admin/welfare/fee/AdminFeeApi.java | 74 ++++++++----------- .../admin/welfare/fee/AdminFeeController.java | 62 ++++++---------- .../api/admin/welfare/fee/FeeAccount.java | 32 ++++++++ .../admin/welfare/fee/FeeAccountProvider.java | 41 ++++++++++ .../welfare/fee/FeeAccountValidator.java | 39 ++++++++++ .../fee/request/FeeAccountUpdateRequest.java | 21 ++++++ .../fee/request/FeeAmountUpdateRequest.java | 12 --- .../fee/request/FeeLinkUpdateRequest.java | 14 ---- .../fee/request/FeeStatusUpdateRequest.java | 14 +++- .../fee/response/AdminFeeSearchResponse.java | 59 +++++++++++---- .../fee/response/FeeAccountResponse.java | 16 ++++ .../fee/response/FeeAmountUpdateResponse.java | 7 -- .../fee/response/FeeLinkUpdateResponse.java | 7 -- .../fee/response/FeeStatusUpdateResponse.java | 17 ++++- .../fee/usecase/AdminFeeReviewUseCase.java | 31 ++++++-- .../fee/usecase/AdminFeeSearchUseCase.java | 58 +++++++++------ .../welfare/fee/usecase/MemberFeeStatus.java | 10 --- .../StudentTransferStatusRepository.java | 1 + .../service/StudentTransferStatusService.java | 4 + .../StudentTransferStatusServiceImpl.java | 12 +++ .../StudentTransferStatusJpaRepository.java | 2 + .../StudentTransferStatusRepositoryImpl.java | 7 ++ 22 files changed, 361 insertions(+), 179 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountProvider.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAccountUpdateRequest.java delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAmountUpdateRequest.java delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeLinkUpdateRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAmountUpdateResponse.java delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeLinkUpdateResponse.java delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/MemberFeeStatus.java 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 57bfb9fb..1b4f29e0 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 @@ -2,36 +2,32 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; -import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.api.common.dto.PageParams; -import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.api.admin.AdminApiUser; -import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAmountUpdateRequest; -import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeLinkUpdateRequest; +import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAccountUpdateRequest; import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeStatusUpdateRequest; import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse; -import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAmountUpdateResponse; -import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeLinkUpdateResponse; +import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAccountResponse; import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeStatusUpdateResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.common.CommonErrorCode; -import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; import org.springdoc.core.annotations.ParameterObject; /** * 운영진 학생회비 API의 문서 명세. 구현은 {@link AdminFeeController}가 맡는다. *

- * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 - * 파라미터 바인딩(@{@code ModelAttribute}, @{@code RequestBody} 등), 부서 권한(@{@code RequireDepartment})은 - * 구현체에 둔다. + * 계좌와 학기당 회비는 한 화면에서 같이 저장하므로 하나의 엔드포인트(`/account`)로 묶여 있다. */ -@Tag(name = "학생회비 관리", description = "운영진 학생회비 납부 확인·설정") +@Tag(name = "학생회비 (운영진)", description = "학생회비 납부 관리") public interface AdminFeeApi { - /** 납부 확인 요청 목록. 상태·키워드로 필터링하고 페이지네이션한다. */ - @Operation(summary = "납부 확인 요청 목록 조회", - description = "학생회비 납부 확인 요청을 상태(status)와 이름·학번 키워드(keyword)로 필터링해 페이지 단위로 조회한다.") + /** 납부자 목록. 회원 기준이라 납부 정보를 내지 않은 "납부 전" 회원도 포함된다. */ + @Operation(summary = "학생회비 납부자 목록 조회", + description = "이름·학번 검색과 납부 상태 필터로 회원을 조회한다. 납부 정보를 내지 않은 회원도 " + + "납부 전으로 함께 나오며, 그런 행은 studentTransferStatusId를 비롯한 납부 값이 모두 null이다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_TRANSFER_STATUS"}) ApiResponse> getRequests( @@ -41,36 +37,30 @@ ApiResponse> getRequests( @ParameterObject PageParams pageParams ); - /** 납부 확인 요청 처리. 승인/반려로 상태를 바꾸고 납부자 명부에 반영한다. */ - @Operation(summary = "납부 확인 요청 처리", - description = "납부 확인 요청을 승인 또는 반려 처리하고 납부자 명부를 갱신한다.") - @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode( - type = FeeErrorCode.class, - codes = {"INVALID_TRANSFER_STATUS", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_ALREADY_REVIEWED"} - ) - @ApiErrorCode(type = MemberErrorCode.class, codes = {"MEMBER_NOT_FOUND"}) + /** 납부 상태 처리. 이미 처리한 요청도 다시 바꿀 수 있다. */ + @Operation(summary = "학생회비 납부 상태 수정", + description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. " + + "확인필요(NEEDS_CHECK)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. " + + "확인필요 학생은 직접 재제출할 수 없으므로, 다시 내게 하려면 납부 전(UNPAID)으로 되돌린다.") + @ApiErrorCode(type = FeeErrorCode.class, + codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND"}) ApiResponse updateStatus( AdminApiUser apiUser, - Long transferStatusId, + Long studentTransferStatusId, FeeStatusUpdateRequest request ); - /** 송금 링크(은행·계좌번호) 설정. */ - @Operation(summary = "송금 링크 설정", - description = "학생회비 송금에 쓰이는 은행과 계좌번호를 설정한다.") - @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - ApiResponse updateLink( - AdminApiUser apiUser, - FeeLinkUpdateRequest request - ); + /** 회비 납부 설정 조회. 수정 폼의 초기값으로 쓴다. */ + @Operation(summary = "회비 납부 계좌 조회", + description = "설정된 은행·계좌번호·학기당 회비를 조회한다. 아직 설정하지 않았으면 은행과 계좌번호는 null이고 " + + "학기당 회비는 기본값이 내려간다.") + ApiResponse getAccount(AdminApiUser apiUser); - /** 학생회비 금액 설정. */ - @Operation(summary = "학생회비 금액 설정", - description = "학생회비 송금 금액을 설정한다.") - @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - ApiResponse updateAmount( - AdminApiUser apiUser, - FeeAmountUpdateRequest request - ); + /** 회비 납부 설정 변경. 계좌와 학기당 회비를 함께 받는다. */ + @Operation(summary = "회비 납부 계좌 변경", + description = "은행·계좌번호·학기당 회비를 한 번에 저장한다. 은행은 허용 목록 중 하나여야 하고 " + + "계좌번호는 하이픈을 제외한 숫자 10~14자리다. 송금 링크는 저장된 값으로 조회 시점에 만들어진다.") + @ApiErrorCode(type = FeeErrorCode.class, + codes = {"INVALID_BANK", "INVALID_ACCOUNT_NO", "INVALID_TRANSFER_AMOUNT"}) + ApiResponse updateAccount(AdminApiUser apiUser, FeeAccountUpdateRequest request); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeController.java index 735ea965..d65b8ced 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeController.java @@ -1,26 +1,20 @@ package kr.ac.kookmin.stream.api.admin.welfare.fee; import jakarta.validation.Valid; -import java.util.Map; -import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.api.common.dto.PageParams; -import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.security.RequireDepartment; -import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAmountUpdateRequest; -import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeLinkUpdateRequest; +import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAccountUpdateRequest; import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeStatusUpdateRequest; import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse; -import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAmountUpdateResponse; -import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeLinkUpdateResponse; +import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAccountResponse; import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeStatusUpdateResponse; import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.AdminFeeReviewUseCase; import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.AdminFeeSearchUseCase; -import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.MemberFeeStatus; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.common.CouncilDepartment; import kr.ac.kookmin.stream.common.PageResult; -import kr.ac.kookmin.stream.internal.domain.config.service.AdminConfigValueService; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; import lombok.RequiredArgsConstructor; @@ -39,9 +33,9 @@ @RequiredArgsConstructor public class AdminFeeController implements AdminFeeApi { - private final AdminConfigValueService adminConfigValueService; private final AdminFeeSearchUseCase adminFeeSearchUseCase; private final AdminFeeReviewUseCase adminFeeReviewUseCase; + private final FeeAccountProvider feeAccountProvider; @Override @GetMapping("/requests") @@ -52,51 +46,43 @@ public ApiResponse> getRequests( @RequestParam(required = false) String keyword, @Valid @ModelAttribute PageParams pageParams ) { - TransferStatus transferStatus = TransferStatus.from(status); - PageResult result = adminFeeSearchUseCase.search(transferStatus, keyword, pageParams.toOffset()); + PageResult result = adminFeeSearchUseCase.search( + TransferStatus.from(status), keyword, pageParams.toOffset()); - return ApiResponse.success( - PageResponse.from(result, (vo) -> AdminFeeSearchResponse.of(vo.status(), vo.member())) - ); + return ApiResponse.success(PageResponse.from(result, response -> response)); } @Override - @PatchMapping("/requests/{transferStatusId}") + @PatchMapping("/requests/{studentTransferStatusId}") @RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS) public ApiResponse updateStatus( AdminApiUser apiUser, - @PathVariable Long transferStatusId, - @Valid @RequestBody FeeStatusUpdateRequest request + @PathVariable Long studentTransferStatusId, + @RequestBody FeeStatusUpdateRequest request ) { - TransferStatus status = TransferStatus.from(request.status()); - StudentTransferStatus reviewed = adminFeeReviewUseCase.review(transferStatusId, status); + StudentTransferStatus reviewed = adminFeeReviewUseCase.review( + studentTransferStatusId, TransferStatus.from(request.status()), request.adminNote()); return ApiResponse.success(FeeStatusUpdateResponse.from(reviewed)); } @Override - @PutMapping("/link") + @GetMapping("/account") @RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS) - public ApiResponse updateLink( - AdminApiUser apiUser, - @Valid @RequestBody FeeLinkUpdateRequest request - ) { - adminConfigValueService.upsertValues(Map.of( - FeeConfigKeys.TRANSFER_BANK, request.bank(), - FeeConfigKeys.TRANSFER_ACCOUNT_NO, request.accountNo() - )); - - return ApiResponse.success(FeeLinkUpdateResponse.of(request.bank(), request.accountNo())); + public ApiResponse getAccount(AdminApiUser apiUser) { + return ApiResponse.success(FeeAccountResponse.from(feeAccountProvider.getAccount())); } @Override - @PutMapping("/amount") + @PutMapping("/account") @RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS) - public ApiResponse updateAmount( + public ApiResponse updateAccount( AdminApiUser apiUser, - @Valid @RequestBody FeeAmountUpdateRequest request + @RequestBody FeeAccountUpdateRequest request ) { - adminConfigValueService.upsertValue(FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(request.amount())); - return ApiResponse.success(FeeAmountUpdateResponse.of(request.amount())); + FeeAccountValidator.validate(request); + FeeAccount saved = feeAccountProvider.save(request.bank(), request.accountNo(), request.amount()); + + return ApiResponse.success(FeeAccountResponse.from(saved)); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java new file mode 100644 index 00000000..e2dd49f9 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java @@ -0,0 +1,32 @@ +package kr.ac.kookmin.stream.api.admin.welfare.fee; + +import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; +import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator; + +/** + * 관리자가 설정한 회비 납부 설정. 계좌는 전역 하나이고 학년도별로 나누지 않는다. + *

+ * {@code api:app-api}에도 같은 모양의 record가 있다. 공유하지 않는 이유는 둘을 담을 자리가 없어서다 — + * {@code api:common-api}는 welfare·internal 모듈을 모르고, 그 의존을 추가하면 "role 무관 공통 인프라"라는 + * 그 모듈의 역할이 깨진다. 설정 키는 {@code FeeConfigKeys} 하나를 같이 쓰므로 값이 어긋나지는 않는다. + * + * @param bank 설정 전이면 null + * @param accountNo 설정 전이면 null + * @param amount 학기당 회비. 설정 전이면 기본값 + */ +public record FeeAccount(String bank, String accountNo, long amount) { + + public boolean isConfigured() { + return bank != null && accountNo != null; + } + + /** 금액이 빠진 송금 기본 링크. 계좌가 설정되기 전이면 null이다. */ + public String transferLinkUrl() { + return isConfigured() ? TossTransferLinkGenerator.generate(bank, accountNo, null) : null; + } + + /** 남은 학기 수를 낸 학생의 납부 금액. 아직 내지 않았으면 null이다. */ + public Long transferAmountFor(Integer remainingSemesters) { + return remainingSemesters == null ? null : FeeAmountCalculator.calculate(remainingSemesters, amount); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountProvider.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountProvider.java new file mode 100644 index 00000000..f6bf9dc7 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountProvider.java @@ -0,0 +1,41 @@ +package kr.ac.kookmin.stream.api.admin.welfare.fee; + +import java.util.Map; +import kr.ac.kookmin.stream.internal.domain.config.service.AdminConfigValueService; +import kr.ac.kookmin.stream.welfare.WelfareConstants; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 회비 납부 설정을 읽고 쓴다. welfare 도메인은 internal 모듈에 의존할 수 없어 이 조합을 api에 둔다. + *

+ * 학생 쪽과 달리 계좌가 없어도 예외를 던지지 않는다 — 관리자 화면은 "아직 설정하지 않음"을 보여주고 + * 그 자리에서 설정하게 해야 하기 때문이다. + */ +@Component +@RequiredArgsConstructor +public class FeeAccountProvider { + + private final AdminConfigValueService adminConfigValueService; + + public FeeAccount getAccount() { + return new FeeAccount( + adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null), + adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null), + adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT) + .map(Long::parseLong) + .orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT) + ); + } + + // 계좌와 금액은 한 화면에서 같이 저장하므로 세 키를 한 번에 쓴다 + public FeeAccount save(String bank, String accountNo, long amount) { + adminConfigValueService.upsertValues(Map.of( + FeeConfigKeys.TRANSFER_BANK, bank, + FeeConfigKeys.TRANSFER_ACCOUNT_NO, accountNo, + FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(amount) + )); + return new FeeAccount(bank, accountNo, amount); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java new file mode 100644 index 00000000..caac6de9 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.api.admin.welfare.fee; + +import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAccountUpdateRequest; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeBanks; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; + +/** + * 회비 납부 설정 입력 검증. 항목마다 전용 에러 코드를 내려줘야 해서 Bean Validation 대신 직접 본다 + * (fee-domain-study 08번 문서 — 애노테이션으로는 공통 INVALID_INPUT이 된다). + */ +final class FeeAccountValidator { + + private static final int MIN_ACCOUNT_NO_LENGTH = 10; + private static final int MAX_ACCOUNT_NO_LENGTH = 14; + + private FeeAccountValidator() {} + + static void validate(FeeAccountUpdateRequest request) { + FeeBanks.validate(request.bank()); + validateAccountNo(request.accountNo()); + validateAmount(request.amount()); + } + + private static void validateAccountNo(String accountNo) { + if (accountNo == null + || !accountNo.matches("^[0-9]+$") + || accountNo.length() < MIN_ACCOUNT_NO_LENGTH + || accountNo.length() > MAX_ACCOUNT_NO_LENGTH) { + throw new BusinessException(FeeErrorCode.INVALID_ACCOUNT_NO); + } + } + + private static void validateAmount(Integer amount) { + if (amount == null || amount < 1) { + throw new BusinessException(FeeErrorCode.INVALID_TRANSFER_AMOUNT); + } + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAccountUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAccountUpdateRequest.java new file mode 100644 index 00000000..e46367bc --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAccountUpdateRequest.java @@ -0,0 +1,21 @@ +package kr.ac.kookmin.stream.api.admin.welfare.fee.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 회비 납부 설정 변경. 계좌와 학기당 회비를 한 화면에서 같이 저장하므로 함께 받는다. + *

+ * 검증에 Bean Validation을 쓰지 않는다 — 항목마다 전용 에러 코드를 내려줘야 하는데 + * 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(fee-domain-study 08번 문서). + */ +public record FeeAccountUpdateRequest( + @Schema(description = "은행명 (허용 목록 중 하나)", example = "카카오뱅크") + String bank, + + @Schema(description = "계좌번호 (하이픈 제외, 10~14자리)", example = "3333011234567") + String accountNo, + + @Schema(description = "학기당 학생회비", example = "20000") + Integer amount +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAmountUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAmountUpdateRequest.java deleted file mode 100644 index b6234203..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAmountUpdateRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.welfare.fee.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; - -public record FeeAmountUpdateRequest( - - @NotNull(message = "유효하지 않은 금액입니다.") - @Positive(message = "유효하지 않은 금액입니다.") - Integer amount -) { -} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeLinkUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeLinkUpdateRequest.java deleted file mode 100644 index 861e8967..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeLinkUpdateRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.welfare.fee.request; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; - -public record FeeLinkUpdateRequest( - @NotBlank(message = "은행명을 입력해 주세요.") - String bank, - - @NotBlank(message = "계좌번호를 입력해 주세요.") - @Pattern(regexp = "^[0-9]+$", message = "계좌번호는 숫자만 입력할 수 있습니다.") - String accountNo -) { -} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeStatusUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeStatusUpdateRequest.java index 195441cf..30400142 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeStatusUpdateRequest.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeStatusUpdateRequest.java @@ -1,9 +1,17 @@ package kr.ac.kookmin.stream.api.admin.welfare.fee.request; -import jakarta.validation.constraints.NotBlank; +import io.swagger.v3.oas.annotations.media.Schema; +/** + * 납부 상태 변경. 상태 문자열 파싱은 {@code TransferStatus.from}이 하고, + * 안내 문구는 확인필요일 때만 필수라 Bean Validation 대신 UseCase에서 검증한다. + */ public record FeeStatusUpdateRequest( - @NotBlank(message = "처리 상태를 입력해 주세요.") - String status + @Schema(description = "변경할 납부 상태", example = "NEEDS_CHECK") + String status, + + @Schema(description = "관리자 안내 문구 (최대 255자). 확인필요일 때 필수", + example = "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.") + String adminNote ) { } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java index 6f6fdab6..26d35bf3 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java @@ -1,31 +1,64 @@ package kr.ac.kookmin.stream.api.admin.welfare.fee.response; import java.time.LocalDateTime; -import kr.ac.kookmin.stream.api.common.ApiConstants; +import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; +/** + * 납부자 목록의 한 줄. 목록은 회원 기준이라 납부 정보를 내지 않은 회원도 들어온다 — + * 그런 회원은 {@code studentTransferStatusId}를 비롯한 납부 관련 값이 모두 null이고 상태는 납부 전이다. + */ public record AdminFeeSearchResponse( Long studentTransferStatusId, Long memberId, String name, String studentId, String department, - String status, + boolean isStreamMember, + String feeStatus, + Integer remainingSemesters, + Long transferAmount, + String proofImageUrl, LocalDateTime requestedAt, - LocalDateTime reviewedAt + LocalDateTime confirmedAt, + String adminNote ) { - // member가 null이면 탈퇴(소프트 삭제)한 회원의 과거 요청이다 — 이름/학번/학과를 알 수 없으니 표시용 placeholder를 쓴다. - public static AdminFeeSearchResponse of(StudentTransferStatus status, Member member) { + /** + * @param transferStatus 납부 정보를 내지 않은 회원이면 null + * @param signedUp 회원가입(전화번호 + 필수 약관 동의)을 마쳤는지 + */ + public static AdminFeeSearchResponse of( + Member member, + StudentTransferStatus transferStatus, + boolean signedUp, + FeeAccount account + ) { + if (transferStatus == null) { + return new AdminFeeSearchResponse( + null, member.getId(), member.getName(), member.getStudentId(), + member.getDepartment().displayName(), signedUp, TransferStatus.UNPAID.name(), + null, null, null, null, null, null); + } + + boolean paid = transferStatus.getStatus() == TransferStatus.PAID; + return new AdminFeeSearchResponse( - status.getId(), - status.getMemberId(), - member != null ? member.getName() : ApiConstants.WITHDRAWN_MEMBER_LABEL, - member != null ? member.getStudentId() : null, - member != null ? member.getDepartment().displayName() : null, - status.getStatus().name(), - status.getCreatedAt(), - status.getReviewedAt() + transferStatus.getId(), + member.getId(), + member.getName(), + member.getStudentId(), + member.getDepartment().displayName(), + signedUp, + transferStatus.getStatus().name(), + transferStatus.getRemainingSemesters(), + account.transferAmountFor(transferStatus.getRemainingSemesters()), + StorageUrlBuilder.build(transferStatus.getProofImageKey()), + transferStatus.getRequestedAt(), + paid ? transferStatus.getReviewedAt() : null, + transferStatus.getAdminNote() ); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java new file mode 100644 index 00000000..807042e9 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.api.admin.welfare.fee.response; + +import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount; + +/** 회비 납부 설정. 조회와 변경이 같은 모양을 쓴다. */ +public record FeeAccountResponse( + String bank, + String accountNo, + long amount, + String transferLinkUrl +) { + public static FeeAccountResponse from(FeeAccount account) { + return new FeeAccountResponse( + account.bank(), account.accountNo(), account.amount(), account.transferLinkUrl()); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAmountUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAmountUpdateResponse.java deleted file mode 100644 index b84eb0eb..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAmountUpdateResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.welfare.fee.response; - -public record FeeAmountUpdateResponse(Integer amount) { - public static FeeAmountUpdateResponse of(Integer amount) { - return new FeeAmountUpdateResponse(amount); - } -} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeLinkUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeLinkUpdateResponse.java deleted file mode 100644 index 6e36681e..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeLinkUpdateResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.welfare.fee.response; - -public record FeeLinkUpdateResponse(String bank, String accountNo) { - public static FeeLinkUpdateResponse of(String bank, String accountNo) { - return new FeeLinkUpdateResponse(bank, accountNo); - } -} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeStatusUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeStatusUpdateResponse.java index bb0c92c9..10e02653 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeStatusUpdateResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeStatusUpdateResponse.java @@ -2,14 +2,25 @@ import java.time.LocalDateTime; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; public record FeeStatusUpdateResponse( Long studentTransferStatusId, Long memberId, String status, - LocalDateTime reviewedAt + String adminNote, + LocalDateTime reviewedAt, + LocalDateTime confirmedAt ) { - public static FeeStatusUpdateResponse from(StudentTransferStatus request) { - return new FeeStatusUpdateResponse(request.getId(), request.getMemberId(), request.getStatus().name(), request.getReviewedAt()); + public static FeeStatusUpdateResponse from(StudentTransferStatus transferStatus) { + return new FeeStatusUpdateResponse( + transferStatus.getId(), + transferStatus.getMemberId(), + transferStatus.getStatus().name(), + transferStatus.getAdminNote(), + transferStatus.getReviewedAt(), + // 납부 확인 시각은 납부완료일 때의 처리 시각이다 + transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null + ); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java index 40d49e03..14cd4ce4 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java @@ -1,7 +1,9 @@ package kr.ac.kookmin.stream.api.admin.welfare.fee.usecase; +import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.member.domain.member.service.MemberService; +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.service.PayerService; @@ -10,22 +12,39 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +/** + * 납부 상태 처리. 납부 상태, 회원 쪽 사본, 납부자 명부 셋이 어긋나면 안 되므로 한 트랜잭션으로 묶는다. + */ @Component @RequiredArgsConstructor public class AdminFeeReviewUseCase { + private static final int MAX_ADMIN_NOTE_LENGTH = 255; + private final StudentTransferStatusService studentTransferStatusService; private final PayerService payerService; private final MemberService memberService; - // 확인요청 처리 + 납부자 명부(Payer) 반영을 하나의 트랜잭션으로 묶는다. @Transactional - public StudentTransferStatus review(Long transferStatusId, TransferStatus status) { - StudentTransferStatus transferStatus = studentTransferStatusService.review(transferStatusId, status); - Member member = memberService.getById(transferStatus.getMemberId()); + public StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote) { + validateAdminNote(status, adminNote); + + StudentTransferStatus reviewed = studentTransferStatusService.review(transferStatusId, status, adminNote); + Member member = memberService.getById(reviewed.getMemberId()); - payerService.sync(member.getId(), member.getName(), member.getStudentId(), transferStatus.getStatus()); + memberService.changeFeeStatus(member.getId(), reviewed.getStatus().name()); + payerService.sync(member.getId(), member.getName(), member.getStudentId(), reviewed.getStatus()); + + return reviewed; + } - return transferStatus; + // 확인필요는 학생에게 보여줄 안내 문구가 있어야 의미가 있다. 그 외 상태에서는 문구를 받아도 버린다 + private void validateAdminNote(TransferStatus status, String adminNote) { + if (status != TransferStatus.NEEDS_CHECK) { + return; + } + if (adminNote == null || adminNote.isBlank() || adminNote.length() > MAX_ADMIN_NOTE_LENGTH) { + throw new BusinessException(FeeErrorCode.INVALID_ADMIN_NOTE); + } } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java index b97ea4c9..20955beb 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java @@ -2,6 +2,10 @@ import java.util.List; import java.util.Map; +import java.util.Set; +import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount; +import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccountProvider; +import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.member.domain.member.domain.Member; @@ -12,34 +16,40 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +/** + * 납부자 목록. {@code member}와 {@code welfare} 두 도메인을 조합한다. + *

+ * 회원을 기준으로 페이징한다. 납부 정보를 내지 않은 회원은 {@code student_transfer_statuses}에 + * 행이 없어서, 납부 쪽을 기준으로 조회하면 "납부 전" 학생이 목록에서 통째로 빠지기 때문이다. + * 상태 필터도 같은 이유로 {@code members.fee_status}에 건다. + *

+ * 트랜잭션을 걸지 않는다 — 쓰기가 없고, 세 조회가 어긋나도 한 줄의 납부 정보가 잠깐 최신이 아닐 뿐이다. + */ @Component @RequiredArgsConstructor public class AdminFeeSearchUseCase { - private final StudentTransferStatusService studentTransferStatusService; private final MemberService memberService; - - public PageResult search(TransferStatus status, String keyword, PageOffset pageOffset) { - List memberIds = null; - - if (keyword != null && !keyword.isBlank()) { - memberIds = memberService.searchIdsByKeyword(keyword); - - if (memberIds.isEmpty()) { - return PageResult.empty(pageOffset.page(), pageOffset.size()); - } - } - - PageResult result = studentTransferStatusService.search(status, memberIds, pageOffset); - List resultMemberIds = result.content() - .stream() - .map(StudentTransferStatus::getMemberId) - .toList(); - - // 탈퇴(소프트 삭제)한 회원은 findAllByIds에서 빠지므로, 그 회원의 과거 요청은 member가 null로 남는다. - // (탈퇴 회원의 요청을 조회 자체에서 제외하는 대신, 표시는 Controller/DTO에서 처리한다) - Map memberMap = memberService.getMapByIds(resultMemberIds); - - return result.map(transferStatus -> new MemberFeeStatus(transferStatus, memberMap.getOrDefault(transferStatus.getMemberId(), null))); + private final StudentTransferStatusService studentTransferStatusService; + private final FeeAccountProvider feeAccountProvider; + + public PageResult search(TransferStatus status, String keyword, PageOffset pageOffset) { + PageResult members = + memberService.searchByFeeStatus(status == null ? null : status.name(), keyword, pageOffset); + + List memberIds = members.content().stream().map(Member::getId).toList(); + Map transferStatusByMemberId = + studentTransferStatusService.findMapByMemberIds(memberIds); + Set signedUpMemberIds = memberService.findSignedUpMemberIds(memberIds); + + // 학기당 금액은 줄마다 같으므로 목록당 한 번만 읽는다 + FeeAccount account = feeAccountProvider.getAccount(); + + return members.map(member -> AdminFeeSearchResponse.of( + member, + transferStatusByMemberId.get(member.getId()), + signedUpMemberIds.contains(member.getId()), + account + )); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/MemberFeeStatus.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/MemberFeeStatus.java deleted file mode 100644 index 5c86a6b3..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/MemberFeeStatus.java +++ /dev/null @@ -1,10 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.welfare.fee.usecase; - -import kr.ac.kookmin.stream.member.domain.member.domain.Member; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; - -// AdminFeeSearchUseCase가 조합한 결과. DTO(AdminFeeSearchResponse) 변환은 Controller가 한다 -// (PageResult<도메인>까지만 UseCase가 만들고, api DTO 조립은 경계에서 하기 위함). -// member는 탈퇴(소프트 삭제)한 회원의 과거 요청이면 null일 수 있다. -public record MemberFeeStatus(StudentTransferStatus status, Member member) { -} 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 d4596599..6264a79f 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 @@ -10,6 +10,7 @@ public interface StudentTransferStatusRepository { Optional findByMemberId(Long memberId); Optional findById(Long id); + List findAllByMemberIds(List memberIds); // memberIds가 null이면 전체 대상, 빈 리스트면 결과 없음을 의도한 것이므로 호출 전에 걸러낸다(빈 리스트로 호출하지 않는다) PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); 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 4dedd902..98b81c80 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 @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.welfare.domain.fee.service; import java.util.List; +import java.util.Map; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; @@ -19,6 +20,9 @@ public interface StudentTransferStatusService { /** 송금 확인을 요청한다. 납부 정보를 제출하지 않았으면 {@code FEE_REQUEST_NOT_FOUND}다. */ StudentTransferStatus requestConfirmation(Long memberId); + /** 회원별 납부 상태. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 제출 전 회원은 빠진다. */ + Map findMapByMemberIds(List memberIds); + // status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음 PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); 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 8eb68282..adff22aa 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 @@ -2,6 +2,9 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.DateUtil; import kr.ac.kookmin.stream.common.PageOffset; @@ -28,6 +31,15 @@ public StudentTransferStatus getByMemberId(Long memberId) { .orElseGet(() -> StudentTransferStatus.notRequested(memberId)); } + @Override + public Map findMapByMemberIds(List memberIds) { + if (memberIds.isEmpty()) { + return Map.of(); + } + return studentTransferStatusRepository.findAllByMemberIds(memberIds).stream() + .collect(Collectors.toMap(StudentTransferStatus::getMemberId, Function.identity())); + } + @Override @Transactional public StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey) { 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 a6856b84..e5e492ba 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 @@ -13,6 +13,8 @@ public interface StudentTransferStatusJpaRepository extends JpaRepository findByMemberId(Long memberId); + List findAllByMemberIdIn(List memberIds); + @Query(value = """ SELECT r FROM StudentTransferStatusJpaEntity r WHERE (:status IS NULL OR r.status = :status) 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 da849279..1d8e325b 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 @@ -23,6 +23,13 @@ public Optional findByMemberId(Long memberId) { return studentTransferStatusJpaRepository.findByMemberId(memberId).map(StudentTransferStatusJpaEntity::toDomain); } + @Override + public List findAllByMemberIds(List memberIds) { + return studentTransferStatusJpaRepository.findAllByMemberIdIn(memberIds).stream() + .map(StudentTransferStatusJpaEntity::toDomain) + .toList(); + } + @Override public Optional findById(Long id) { return studentTransferStatusJpaRepository.findById(id).map(StudentTransferStatusJpaEntity::toDomain); From e969b8e78c114728d8cbdc3dbfec127f4e13eea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:33:34 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=84=20=ED=9A=8C=EB=B9=84=20=EC=AA=BD=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=AC=EA=B3=A0=20member=20=EB=B3=80=EA=B2=BD=EC=9D=84=20?= =?UTF-8?q?=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원 기준으로 페이징하려다 member 도메인에 회비 개념을 넣고 있었다. 공개 인터페이스 세 개(MemberService, MemberTermService, MemberRepository)와 그 구현까지 고치는 범위였고, 회비 기능 때문에 남의 도메인을 바꾸는 꼴이었다. 방향을 뒤집는다. 모든 회원이 납부 상태 행을 갖게 하면(로그인 때 생성, V21로 기존 회원 백필) 납부 전 회원도 회비 쪽 조회에 그대로 걸린다. 그러면 페이징·상태 필터가 회비 테이블에서 끝나고, member 쪽은 이미 공개된 searchIdsByKeyword·getMapByIds·isSignedUp만 쓰면 된다. member 모듈 변경은 전부 되돌렸다. 가입 여부는 줄마다 isSignedUp을 부르므로 조회가 늘어나는데, 목록이 커져 문제가 되면 그때 벌크 조회를 member 쪽에 요청할 자리다. --- .../fee/response/AdminFeeSearchResponse.java | 31 ++++++-------- .../fee/usecase/AdminFeeReviewUseCase.java | 3 +- .../fee/usecase/AdminFeeSearchUseCase.java | 42 +++++++++++-------- .../core/auth/usecase/OAuthLoginUseCase.java | 6 +++ .../member/repository/MemberRepository.java | 5 --- .../MemberTermAgreementRepository.java | 1 - .../domain/member/service/MemberService.java | 9 ---- .../member/service/MemberTermService.java | 5 --- .../service/impl/MemberServiceImpl.java | 22 ---------- .../service/impl/MemberTermServiceImpl.java | 22 ---------- .../service/StudentTransferStatusService.java | 8 ++++ .../StudentTransferStatusServiceImpl.java | 9 ++++ .../stream/db/member/MemberJpaRepository.java | 19 --------- .../db/member/MemberRepositoryImpl.java | 20 --------- .../MemberTermAgreementJpaRepository.java | 2 - .../MemberTermAgreementRepositoryImpl.java | 7 ---- ...sert_default_student_transfer_statuses.sql | 12 ++++++ 17 files changed, 73 insertions(+), 150 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java index 26d35bf3..240b0e10 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java @@ -2,14 +2,15 @@ import java.time.LocalDateTime; import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount; +import kr.ac.kookmin.stream.api.common.ApiConstants; import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; /** - * 납부자 목록의 한 줄. 목록은 회원 기준이라 납부 정보를 내지 않은 회원도 들어온다 — - * 그런 회원은 {@code studentTransferStatusId}를 비롯한 납부 관련 값이 모두 null이고 상태는 납부 전이다. + * 납부자 목록의 한 줄. 모든 회원이 납부 상태 행을 가지므로 아직 아무것도 내지 않은 회원도 들어온다 — + * 그런 줄은 상태가 납부 전이고 제출 관련 값이 모두 null이다. */ public record AdminFeeSearchResponse( Long studentTransferStatusId, @@ -27,37 +28,29 @@ public record AdminFeeSearchResponse( String adminNote ) { /** - * @param transferStatus 납부 정보를 내지 않은 회원이면 null - * @param signedUp 회원가입(전화번호 + 필수 약관 동의)을 마쳤는지 + * @param member 탈퇴(소프트 삭제)한 회원의 행이면 null — 이름·학번·학과를 알 수 없어 표시용 placeholder를 쓴다 + * @param signedUp 회원가입(전화번호 + 필수 약관 동의)을 마쳤는지 */ public static AdminFeeSearchResponse of( - Member member, StudentTransferStatus transferStatus, + Member member, boolean signedUp, FeeAccount account ) { - if (transferStatus == null) { - return new AdminFeeSearchResponse( - null, member.getId(), member.getName(), member.getStudentId(), - member.getDepartment().displayName(), signedUp, TransferStatus.UNPAID.name(), - null, null, null, null, null, null); - } - - boolean paid = transferStatus.getStatus() == TransferStatus.PAID; - return new AdminFeeSearchResponse( transferStatus.getId(), - member.getId(), - member.getName(), - member.getStudentId(), - member.getDepartment().displayName(), + transferStatus.getMemberId(), + member != null ? member.getName() : ApiConstants.WITHDRAWN_MEMBER_LABEL, + member != null ? member.getStudentId() : null, + member != null ? member.getDepartment().displayName() : null, signedUp, transferStatus.getStatus().name(), transferStatus.getRemainingSemesters(), account.transferAmountFor(transferStatus.getRemainingSemesters()), StorageUrlBuilder.build(transferStatus.getProofImageKey()), transferStatus.getRequestedAt(), - paid ? transferStatus.getReviewedAt() : null, + // 납부 확인 시각은 납부완료일 때의 처리 시각이다 + transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null, transferStatus.getAdminNote() ); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java index 14cd4ce4..a173414f 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java @@ -13,7 +13,7 @@ import org.springframework.transaction.annotation.Transactional; /** - * 납부 상태 처리. 납부 상태, 회원 쪽 사본, 납부자 명부 셋이 어긋나면 안 되므로 한 트랜잭션으로 묶는다. + * 납부 상태 처리. 상태 변경과 납부자 명부 반영이 어긋나면 안 되므로 한 트랜잭션으로 묶는다. */ @Component @RequiredArgsConstructor @@ -32,7 +32,6 @@ public StudentTransferStatus review(Long transferStatusId, TransferStatus status StudentTransferStatus reviewed = studentTransferStatusService.review(transferStatusId, status, adminNote); Member member = memberService.getById(reviewed.getMemberId()); - memberService.changeFeeStatus(member.getId(), reviewed.getStatus().name()); payerService.sync(member.getId(), member.getName(), member.getStudentId(), reviewed.getStatus()); return reviewed; diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java index 20955beb..a0b70ced 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java @@ -2,7 +2,6 @@ import java.util.List; import java.util.Map; -import java.util.Set; import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount; import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccountProvider; import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse; @@ -17,38 +16,47 @@ import org.springframework.stereotype.Component; /** - * 납부자 목록. {@code member}와 {@code welfare} 두 도메인을 조합한다. + * 납부자 목록. {@code welfare}와 {@code member} 두 도메인을 조합한다. *

- * 회원을 기준으로 페이징한다. 납부 정보를 내지 않은 회원은 {@code student_transfer_statuses}에 - * 행이 없어서, 납부 쪽을 기준으로 조회하면 "납부 전" 학생이 목록에서 통째로 빠지기 때문이다. - * 상태 필터도 같은 이유로 {@code members.fee_status}에 건다. + * 페이징과 상태 필터는 회비 쪽에서 한다. 모든 회원이 납부 상태 행을 갖기 때문에(로그인 때 생성, + * {@code V21}로 기존 회원 백필) 납부 전 회원도 그대로 걸린다. 회원 기준으로 뒤집으면 member 도메인이 + * 회비 상태를 알아야 하는데, 회비 때문에 남의 도메인을 고치지 않으려고 방향을 이렇게 잡았다. *

- * 트랜잭션을 걸지 않는다 — 쓰기가 없고, 세 조회가 어긋나도 한 줄의 납부 정보가 잠깐 최신이 아닐 뿐이다. + * member 쪽은 이미 공개된 조회 메서드만 쓴다 — 검색은 {@code searchIdsByKeyword}, 회원 정보는 {@code getMapByIds}. + *

+ * 트랜잭션을 걸지 않는다. 쓰기가 없고, 조회 사이가 어긋나도 한 줄의 정보가 잠깐 최신이 아닐 뿐이다. */ @Component @RequiredArgsConstructor public class AdminFeeSearchUseCase { - private final MemberService memberService; private final StudentTransferStatusService studentTransferStatusService; + private final MemberService memberService; private final FeeAccountProvider feeAccountProvider; public PageResult search(TransferStatus status, String keyword, PageOffset pageOffset) { - PageResult members = - memberService.searchByFeeStatus(status == null ? null : status.name(), keyword, pageOffset); + List memberIds = null; + if (keyword != null && !keyword.isBlank()) { + memberIds = memberService.searchIdsByKeyword(keyword); + if (memberIds.isEmpty()) { + return PageResult.empty(pageOffset.page(), pageOffset.size()); + } + } - List memberIds = members.content().stream().map(Member::getId).toList(); - Map transferStatusByMemberId = - studentTransferStatusService.findMapByMemberIds(memberIds); - Set signedUpMemberIds = memberService.findSignedUpMemberIds(memberIds); + PageResult page = studentTransferStatusService.search(status, memberIds, pageOffset); + List pagedMemberIds = page.content().stream().map(StudentTransferStatus::getMemberId).toList(); + // 탈퇴(소프트 삭제)한 회원은 빠지므로 그 회원의 행은 member가 null로 남는다 + Map membersById = memberService.getMapByIds(pagedMemberIds); // 학기당 금액은 줄마다 같으므로 목록당 한 번만 읽는다 FeeAccount account = feeAccountProvider.getAccount(); - return members.map(member -> AdminFeeSearchResponse.of( - member, - transferStatusByMemberId.get(member.getId()), - signedUpMemberIds.contains(member.getId()), + return page.map(transferStatus -> AdminFeeSearchResponse.of( + transferStatus, + membersById.get(transferStatus.getMemberId()), + // 가입 여부는 member가 아는 사실이라 member의 공개 메서드에 묻는다. + // 줄마다 한 번씩 부르므로 목록이 커지면 벌크 조회를 member 쪽에 요청할 자리다 + memberService.isSignedUp(transferStatus.getMemberId()), account )); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java index a530c56f..e403b29a 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java @@ -8,6 +8,7 @@ import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand; import kr.ac.kookmin.stream.member.domain.member.service.MemberService; import kr.ac.kookmin.stream.security.AuthTokenIssuer; +import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -24,11 +25,16 @@ public class OAuthLoginUseCase { private final OAuthService oauthService; private final MemberService memberService; private final AuthTokenIssuer authTokenIssuer; + private final StudentTransferStatusService studentTransferStatusService; public OAuthLoginResponse login(OAuthLoginCommand command) { OAuthUserInfo userInfo = oauthService.authenticate(command); Member member = findOrRegister(userInfo); + // 모든 회원이 납부 상태 행을 갖게 해서, 관리자 납부자 목록이 납부 전 회원까지 보여줄 수 있게 한다. + // 회비가 자기 테이블에서 전원을 다루므로 member 도메인은 회비를 몰라도 된다. + studentTransferStatusService.createIfAbsent(member.getId()); + String accessToken = authTokenIssuer.issue(member.getId(), member.getRole(), member.getCouncilDepartment()); // 회원을 이번에 만들었는지가 아니라 저장된 전화번호·동의 기록으로 판단한다. 회원가입 화면에서 이탈한 회원도 다시 가입 화면을 본다 boolean signUpRequired = !memberService.isSignedUp(member.getId()); diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java index cae0aeaa..bd0465e3 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java @@ -2,8 +2,6 @@ import java.util.List; import java.util.Optional; -import kr.ac.kookmin.stream.common.PageOffset; -import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.member.domain.member.domain.Member; public interface MemberRepository { @@ -12,9 +10,6 @@ public interface MemberRepository { boolean existsByPhoneNumber(String phoneNumber); List findAllByIds(List ids); List searchIdsByKeyword(String keyword); - - // feeStatus·keyword가 null이면 그 조건은 빼고 전체를 대상으로 한다 - PageResult searchByFeeStatus(String feeStatus, String keyword, PageOffset pageOffset); Member save(Member member); void clearFcmTokenOfOthers(Long memberId, String fcmToken); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java index 83820f9f..86db290b 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java @@ -5,6 +5,5 @@ public interface MemberTermAgreementRepository { List findAllByMemberId(Long memberId); - List findAllByMemberIdIn(List memberIds); void saveAll(List agreements); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java index dbf8ec96..39d1a0fd 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java @@ -2,9 +2,6 @@ import java.util.List; import java.util.Map; -import java.util.Set; -import kr.ac.kookmin.stream.common.PageOffset; -import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand; import kr.ac.kookmin.stream.member.domain.member.domain.MemberSignUpCommand; @@ -14,12 +11,6 @@ public interface MemberService { List findAllByIds(List ids); Map getMapByIds(List ids); List searchIdsByKeyword(String keyword); - - /** 회비 상태와 검색어로 회원을 페이지 단위로 조회한다. 관리자 납부자 목록이 회원 기준이라 필요하다. */ - PageResult searchByFeeStatus(String feeStatus, String keyword, PageOffset pageOffset); - - /** 회원가입(전화번호 + 필수 약관 동의)을 마친 회원의 id. 목록의 Stream 가입 상태에 쓴다. */ - Set findSignedUpMemberIds(List memberIds); Member updateProfile(Long id, MemberProfileCommand command); Member registerOrUpdateByStudentId(MemberProfileCommand command); Member signUp(Long memberId, MemberSignUpCommand command); diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java index 317597fc..49f847d4 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java @@ -1,14 +1,9 @@ package kr.ac.kookmin.stream.member.domain.member.service; -import java.util.List; import java.util.Map; -import java.util.Set; import kr.ac.kookmin.stream.member.domain.member.domain.TermType; public interface MemberTermService { boolean hasAgreedRequiredTerms(Long memberId); - - /** 필수 약관에 모두 동의한 회원의 id. 목록에서 회원마다 조회하지 않으려고 한 번에 가져온다. */ - Set findMemberIdsAgreedRequiredTerms(List memberIds); void agree(Long memberId, Map termAgreements); } diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java index 73795814..f6bdf2d2 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java @@ -2,9 +2,6 @@ import java.util.List; import java.util.Map; -import kr.ac.kookmin.stream.common.PageResult; -import kr.ac.kookmin.stream.common.PageOffset; -import java.util.Set; import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; @@ -98,25 +95,6 @@ public void updateFcmToken(Long memberId, String fcmToken) { } } - @Override - public PageResult searchByFeeStatus(String feeStatus, String keyword, PageOffset pageOffset) { - return memberRepository.searchByFeeStatus(feeStatus, keyword, pageOffset); - } - - // 회원마다 약관을 조회하면 목록 한 장에 쿼리가 페이지 크기만큼 늘어나므로 한 번에 가져와 짝짓는다 - @Override - public Set findSignedUpMemberIds(List memberIds) { - if (memberIds.isEmpty()) { - return Set.of(); - } - Set agreedMemberIds = memberTermService.findMemberIdsAgreedRequiredTerms(memberIds); - - return memberRepository.findAllByIds(memberIds).stream() - .filter(member -> member.hasPhoneNumber() && agreedMemberIds.contains(member.getId())) - .map(Member::getId) - .collect(Collectors.toSet()); - } - // 전화번호와 필수 약관 동의가 모두 있어야 가입을 마친 것으로 본다 private boolean isSignedUp(Member member) { return member.hasPhoneNumber() && memberTermService.hasAgreedRequiredTerms(member.getId()); diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java index 0631ca94..40a35149 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java @@ -23,28 +23,6 @@ class MemberTermServiceImpl implements MemberTermService { private final MemberTermAgreementRepository memberTermAgreementRepository; - @Override - public Set findMemberIdsAgreedRequiredTerms(List memberIds) { - if (memberIds.isEmpty()) { - return Set.of(); - } - - Map> agreedByMemberId = memberTermAgreementRepository.findAllByMemberIdIn(memberIds).stream() - .filter(MemberTermAgreement::isAgreed) - .collect(Collectors.groupingBy( - MemberTermAgreement::getMemberId, - Collectors.mapping(MemberTermAgreement::getTermType, Collectors.toSet()))); - - Set requiredTermTypes = Arrays.stream(TermType.values()) - .filter(TermType::required) - .collect(Collectors.toSet()); - - return agreedByMemberId.entrySet().stream() - .filter(entry -> entry.getValue().containsAll(requiredTermTypes)) - .map(Map.Entry::getKey) - .collect(Collectors.toSet()); - } - @Override public boolean hasAgreedRequiredTerms(Long memberId) { Set agreedTermTypes = memberTermAgreementRepository.findAllByMemberId(memberId).stream() 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 98b81c80..f99c59bc 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 @@ -11,6 +11,14 @@ public interface StudentTransferStatusService { StudentTransferStatus getByMemberId(Long memberId); + /** + * 회원의 납부 상태 행을 만든다. 이미 있으면 아무것도 하지 않는다. + *

+ * 모든 회원이 행을 갖게 해서 관리자 목록이 납부 전 회원까지 보여줄 수 있게 한다. 행이 없는 회원을 + * 목록에 넣으려면 회원 기준으로 조회해야 하고, 그러면 member 도메인이 회비를 알아야 한다. + */ + void createIfAbsent(Long memberId); + /** * 납부 정보(남은 학기 수·학점이수현황 캡처)를 저장한다. 상태는 납부 전 그대로다. * 행이 없으면 만들고, 있으면 이전 입력값을 덮어쓴다. 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 adff22aa..95c87735 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 @@ -40,6 +40,15 @@ public Map findMapByMemberIds(List memberIds) .collect(Collectors.toMap(StudentTransferStatus::getMemberId, Function.identity())); } + @Override + @Transactional + public void createIfAbsent(Long memberId) { + if (studentTransferStatusRepository.findByMemberId(memberId).isPresent()) { + return; + } + studentTransferStatusRepository.save(StudentTransferStatus.create(memberId)); + } + @Override @Transactional public StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey) { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java index d7515b66..0fd828a2 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java @@ -2,8 +2,6 @@ import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -26,23 +24,6 @@ public interface MemberJpaRepository extends JpaRepository searchIdsByKeyword(@Param("keyword") String keyword); - // 관리자 납부자 목록은 납부 정보를 내지 않은 회원도 보여줘야 해서 회원 기준으로 조회한다. - // feeStatus 필터는 idx_members_fee_status_is_deleted(V20)를 탄다. - @Query(""" - SELECT m FROM MemberJpaEntity m - WHERE m.isDeleted = false - AND (:feeStatus IS NULL OR m.feeStatus = :feeStatus) - AND (:keyword IS NULL - OR m.name LIKE CONCAT('%', :keyword, '%') - OR m.studentId LIKE CONCAT('%', :keyword, '%')) - ORDER BY m.id DESC - """) - Page searchByFeeStatus( - @Param("feeStatus") String feeStatus, - @Param("keyword") String keyword, - Pageable pageable - ); - // 탈퇴 회원에게는 어차피 보내지 않으므로 is_deleted와 관계없이 지운다 @Modifying(clearAutomatically = true) @Query(""" diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java index cbd63877..0dba47a5 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java @@ -9,10 +9,6 @@ import kr.ac.kookmin.stream.member.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; -import kr.ac.kookmin.stream.common.PageOffset; -import kr.ac.kookmin.stream.common.PageResult; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @Repository @@ -48,22 +44,6 @@ public List findAllByIds(List ids) { .toList(); } - @Override - public PageResult searchByFeeStatus(String feeStatus, String keyword, PageOffset pageOffset) { - Page result = memberJpaRepository.searchByFeeStatus( - feeStatus, - keyword == null || keyword.isBlank() ? null : keyword, - PageRequest.of(pageOffset.page(), pageOffset.size()) - ); - - return PageResult.of( - result.getContent().stream().map(MemberJpaEntity::toDomain).toList(), - result.getNumber(), - result.getSize(), - result.getTotalElements() - ); - } - @Override public List searchIdsByKeyword(String keyword) { return memberJpaRepository.searchIdsByKeyword(keyword); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java index 0595a9a9..35637f1f 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java @@ -5,6 +5,4 @@ public interface MemberTermAgreementJpaRepository extends JpaRepository { List findAllByMemberId(Long memberId); - - List findAllByMemberIdIn(List memberIds); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java index 19a87920..d65d7280 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java @@ -12,13 +12,6 @@ public class MemberTermAgreementRepositoryImpl implements MemberTermAgreementRep private final MemberTermAgreementJpaRepository memberTermAgreementJpaRepository; - @Override - public List findAllByMemberIdIn(List memberIds) { - return memberTermAgreementJpaRepository.findAllByMemberIdIn(memberIds).stream() - .map(MemberTermAgreementJpaEntity::toDomain) - .toList(); - } - @Override public List findAllByMemberId(Long memberId) { return memberTermAgreementJpaRepository.findAllByMemberId(memberId).stream() diff --git a/infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql b/infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql new file mode 100644 index 00000000..fc6b107c --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql @@ -0,0 +1,12 @@ +-- 납부 상태 행은 회원이 만들어질 때(첫 로그인) 함께 만든다. 그 전에 들어온 회원에게도 납부 전 행을 만들어 둔다. +-- 관리자 납부자 목록이 "납부 전" 회원까지 보여주려면 모든 회원에게 행이 있어야 한다. +-- 행이 없는 회원을 목록에 넣으려면 회원 기준으로 조회해야 하는데, 그러면 member 도메인이 회비를 알아야 한다. +INSERT INTO student_transfer_statuses (member_id, status) +SELECT m.member_id, 'UNPAID' +FROM members m +WHERE m.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM student_transfer_statuses s + WHERE s.member_id = m.member_id + ); From d18e49d30ea13f4742f9c6e693ff0c6a250bd1a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:57:23 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9D=BC=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transferAmountFor·transferLinkFor는 조회처럼 읽히지만 실제로는 계산과 생성이다. 저장소에 이미 FeeAmountCalculator.calculate와 TossTransferLinkGenerator.generate가 있어 그 쪽에 맞춘다. Map 조회는 MemberService.getMapByIds 선례를 따른다. FeeTransferUrlProvider는 더 이상 URL만 주지 않고 계좌 설정을 돌려주므로 FeeAccountProvider로 바꿨다. 관리자 쪽과 이름도 같아진다. --- .../ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java | 4 ++-- .../admin/welfare/fee/response/AdminFeeSearchResponse.java | 2 +- .../api/admin/welfare/fee/response/FeeAccountResponse.java | 2 +- .../stream/api/app/welfare/fee/AppFeeController.java | 4 ++-- .../ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java | 4 ++-- ...{FeeTransferUrlProvider.java => FeeAccountProvider.java} | 2 +- .../stream/api/app/welfare/fee/response/FeeResponse.java | 4 ++-- .../api/app/welfare/fee/response/FeeSubmitResponse.java | 4 ++-- .../api/app/welfare/fee/usecase/FeeSubmitUseCase.java | 6 +++--- .../domain/fee/service/StudentTransferStatusService.java | 2 +- .../fee/service/impl/StudentTransferStatusServiceImpl.java | 2 +- 11 files changed, 18 insertions(+), 18 deletions(-) rename api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/{FeeTransferUrlProvider.java => FeeAccountProvider.java} (97%) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java index e2dd49f9..29a9f047 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java @@ -21,12 +21,12 @@ public boolean isConfigured() { } /** 금액이 빠진 송금 기본 링크. 계좌가 설정되기 전이면 null이다. */ - public String transferLinkUrl() { + public String generateTransferLinkUrl() { return isConfigured() ? TossTransferLinkGenerator.generate(bank, accountNo, null) : null; } /** 남은 학기 수를 낸 학생의 납부 금액. 아직 내지 않았으면 null이다. */ - public Long transferAmountFor(Integer remainingSemesters) { + public Long calculateTransferAmount(Integer remainingSemesters) { return remainingSemesters == null ? null : FeeAmountCalculator.calculate(remainingSemesters, amount); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java index 240b0e10..3a833d28 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java @@ -46,7 +46,7 @@ public static AdminFeeSearchResponse of( signedUp, transferStatus.getStatus().name(), transferStatus.getRemainingSemesters(), - account.transferAmountFor(transferStatus.getRemainingSemesters()), + account.calculateTransferAmount(transferStatus.getRemainingSemesters()), StorageUrlBuilder.build(transferStatus.getProofImageKey()), transferStatus.getRequestedAt(), // 납부 확인 시각은 납부완료일 때의 처리 시각이다 diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java index 807042e9..be558e2e 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java @@ -11,6 +11,6 @@ public record FeeAccountResponse( ) { public static FeeAccountResponse from(FeeAccount account) { return new FeeAccountResponse( - account.bank(), account.accountNo(), account.amount(), account.transferLinkUrl()); + account.bank(), account.accountNo(), account.amount(), account.generateTransferLinkUrl()); } } 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 56c9e2cb..882ab145 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 @@ -22,7 +22,7 @@ public class AppFeeController implements AppFeeApi { private final StudentTransferStatusService studentTransferStatusService; - private final FeeTransferUrlProvider feeTransferUrlProvider; + private final FeeAccountProvider feeAccountProvider; private final FeeSubmitUseCase feeSubmitUseCase; @Override @@ -30,7 +30,7 @@ public class AppFeeController implements AppFeeApi { public ApiResponse getFeeByMemberId(AppApiUser apiUser) { StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId()); - return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getAccount())); + return ApiResponse.success(FeeResponse.of(transferStatus, feeAccountProvider.getAccount())); } @Override diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java index a2812b66..5d7a2f3e 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java @@ -12,12 +12,12 @@ public record FeeAccount(String bank, String accountNo, long amountPerSemester) { /** 이 학생이 보낼 금액. 남은 학기 수 × 학기당 회비. */ - public long transferAmountFor(int remainingSemesters) { + public long calculateTransferAmount(int remainingSemesters) { return FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester); } /** 금액이 붙은 토스 송금 링크. */ - public String transferLinkFor(long transferAmount) { + public String generateTransferLinkUrl(long transferAmount) { return TossTransferLinkGenerator.generate(bank, accountNo, transferAmount); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java similarity index 97% rename from api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java rename to api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java index ea3493ed..85c2f6c8 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java @@ -17,7 +17,7 @@ */ @Component @RequiredArgsConstructor -public class FeeTransferUrlProvider { +public class FeeAccountProvider { private final AdminConfigValueService adminConfigValueService; diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java index b7c09924..9d69afa3 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java @@ -30,7 +30,7 @@ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount ac Integer remainingSemesters = transferStatus.getRemainingSemesters(); boolean payable = remainingSemesters != null && transferStatus.getStatus() != TransferStatus.NEEDS_CHECK; - Long transferAmount = payable ? account.transferAmountFor(remainingSemesters) : null; + Long transferAmount = payable ? account.calculateTransferAmount(remainingSemesters) : null; return new FeeResponse( transferStatus.getId(), @@ -39,7 +39,7 @@ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount ac account.accountNo(), remainingSemesters, transferAmount, - transferAmount == null ? null : account.transferLinkFor(transferAmount), + transferAmount == null ? null : account.generateTransferLinkUrl(transferAmount), transferStatus.getRequestedAt(), // 납부 확인 시각은 PAID일 때의 처리 시각이다. 그 외 상태에서는 보여주지 않는다 transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null, diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java index 73eca1fb..bc37cf02 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java @@ -11,10 +11,10 @@ public record FeeSubmitResponse( String transferLinkUrl ) { public static FeeSubmitResponse of(int remainingSemesters, FeeAccount account) { - long transferAmount = account.transferAmountFor(remainingSemesters); + long transferAmount = account.calculateTransferAmount(remainingSemesters); return new FeeSubmitResponse( account.bank(), account.accountNo(), remainingSemesters, - transferAmount, account.transferLinkFor(transferAmount)); + transferAmount, account.generateTransferLinkUrl(transferAmount)); } } 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 2a3861de..3d9d55bd 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 @@ -2,7 +2,7 @@ import java.util.Objects; import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount; -import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferUrlProvider; +import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccountProvider; import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse; import kr.ac.kookmin.stream.common.BusinessException; @@ -34,7 +34,7 @@ public class FeeSubmitUseCase { private static final int MAX_REMAINING_SEMESTERS = 8; private final StudentTransferStatusService studentTransferStatusService; - private final FeeTransferUrlProvider feeTransferUrlProvider; + private final FeeAccountProvider feeAccountProvider; private final FileService fileService; public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) { @@ -42,7 +42,7 @@ public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) { validateProofImage(request.proofImageKey(), memberId); // 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다 - FeeAccount account = feeTransferUrlProvider.getAccount(); + FeeAccount account = feeAccountProvider.getAccount(); // 교체될 이전 키를 알려고 한 번 더 읽는다. submit이 돌려주게 하면 반환 타입에 값을 하나 더 얹어야 해서, // 유니크 인덱스 조회 한 번을 받아들였다. 같은 학생의 동시 제출이 겹치면 낡은 키를 볼 수 있는데, 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 f99c59bc..525f48ac 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 @@ -29,7 +29,7 @@ public interface StudentTransferStatusService { StudentTransferStatus requestConfirmation(Long memberId); /** 회원별 납부 상태. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 제출 전 회원은 빠진다. */ - Map findMapByMemberIds(List memberIds); + Map getMapByMemberIds(List memberIds); // status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음 PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); 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 95c87735..f6cfa6c6 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 @@ -32,7 +32,7 @@ public StudentTransferStatus getByMemberId(Long memberId) { } @Override - public Map findMapByMemberIds(List memberIds) { + public Map getMapByMemberIds(List memberIds) { if (memberIds.isEmpty()) { return Map.of(); }