From f77f80a6ed6fef54be84222c0884fb1cb2a14aeb 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: Sun, 25 Oct 2026 21:25:45 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=ED=95=99=EC=A0=90=EC=9D=B4?= =?UTF-8?q?=EC=88=98=ED=98=84=ED=99=A9=20=EC=BA=A1=EC=B2=98=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EC=99=80=20?= =?UTF-8?q?=ED=82=A4=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/ac/kookmin/stream/file/domain/FileCategory.java | 4 +++- .../ac/kookmin/stream/file/repository/FileRepository.java | 1 + .../java/kr/ac/kookmin/stream/file/service/FileService.java | 3 +++ .../kookmin/stream/file/service/impl/FileServiceImpl.java | 5 +++++ .../kookmin/stream/file/service/impl/FileUploadPolicy.java | 6 ++++-- .../kr/ac/kookmin/stream/db/file/FileJpaRepository.java | 4 ++++ .../kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java | 5 +++++ 7 files changed, 25 insertions(+), 3 deletions(-) diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java index 2b11e125..caf0734c 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java @@ -6,5 +6,7 @@ public enum FileCategory { NOTICE_IMAGE, EVENT_IMAGE, ARCHIVE_IMAGE, - LOCKER_SECTION_PHOTO + LOCKER_SECTION_PHOTO, + // 학생회비 납부 때 남은 학기 수의 근거로 올리는 K스마트멘토 학점이수현황 캡처 + FEE_TRANSCRIPT_IMAGE } diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java index f34418bc..1a00be2b 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java @@ -6,6 +6,7 @@ public interface FileRepository { Optional findById(Long id); + Optional findByFileKey(String fileKey); List findAllByIdIn(List ids); File save(File file); void deleteById(Long id); diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java index 444c3c8b..5b81fc42 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java @@ -11,5 +11,8 @@ public interface FileService { FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command); void delete(Long fileId); Optional findById(Long fileId); + + /** 업로드 URL 발급 때 돌려준 키로 찾는다. 제출받은 키가 실재하는지, 누가 올렸는지 확인할 때 쓴다. */ + Optional findByFileKey(String fileKey); Map findAllByIdIn(List fileIds); } diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java index 003f0169..8812e667 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java @@ -63,6 +63,11 @@ public Optional findById(Long fileId) { return fileRepository.findById(fileId); } + @Override + public Optional findByFileKey(String fileKey) { + return fileRepository.findByFileKey(fileKey); + } + @Override public Map findAllByIdIn(List fileIds) { if (fileIds.isEmpty()) { diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java index 6466a7e8..f78bd77b 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java @@ -22,7 +22,8 @@ FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS), FileCategory.NOTICE_IMAGE, IMAGE_EXTENSIONS, FileCategory.EVENT_IMAGE, IMAGE_EXTENSIONS, FileCategory.ARCHIVE_IMAGE, IMAGE_EXTENSIONS, - FileCategory.LOCKER_SECTION_PHOTO, IMAGE_EXTENSIONS + FileCategory.LOCKER_SECTION_PHOTO, IMAGE_EXTENSIONS, + FileCategory.FEE_TRANSCRIPT_IMAGE, IMAGE_EXTENSIONS ); private static final Map MAX_FILE_SIZE = Map.of( @@ -31,7 +32,8 @@ FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS), FileCategory.NOTICE_IMAGE, IMAGE_MAX_FILE_SIZE, FileCategory.EVENT_IMAGE, IMAGE_MAX_FILE_SIZE, FileCategory.ARCHIVE_IMAGE, IMAGE_MAX_FILE_SIZE, - FileCategory.LOCKER_SECTION_PHOTO, IMAGE_MAX_FILE_SIZE + FileCategory.LOCKER_SECTION_PHOTO, IMAGE_MAX_FILE_SIZE, + FileCategory.FEE_TRANSCRIPT_IMAGE, IMAGE_MAX_FILE_SIZE ); private FileUploadPolicy() {} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java index 9b61d699..cb534fde 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java @@ -1,8 +1,12 @@ package kr.ac.kookmin.stream.db.file; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface FileJpaRepository extends JpaRepository { List findAllByIdIn(List ids); + + // file_key는 TEXT라 idx_files_file_key가 prefix(255) 인덱스다(V20) + Optional findByFileKey(String fileKey); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java index 2e1fe512..5d25ebbc 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java @@ -18,6 +18,11 @@ public Optional findById(Long id) { return fileJpaRepository.findById(id).map(FileJpaEntity::toDomain); } + @Override + public Optional findByFileKey(String fileKey) { + return fileJpaRepository.findByFileKey(fileKey).map(FileJpaEntity::toDomain); + } + @Override public List findAllByIdIn(List ids) { return fileJpaRepository.findAllByIdIn(ids).stream().map(FileJpaEntity::toDomain).toList(); From fc3f1d7ebda192189dfe0c1d736ccf5534af2d93 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: Tue, 27 Oct 2026 18:47:33 +0900 Subject: [PATCH 02/18] =?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=EC=9A=94=EC=B2=AD=EC=9D=84=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=EC=9C=BC=EB=A1=9C=20=EC=8C=93=EA=B3=A0=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=C2=B7=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?API=EB=A5=BC=20=EA=B0=9C=ED=8E=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/fee/AdminFeeAccountProvider.java | 41 ++++++++++ .../api/admin/welfare/fee/AdminFeeApi.java | 75 ++++++++----------- .../admin/welfare/fee/AdminFeeController.java | 62 ++++++--------- .../api/admin/welfare/fee/FeeAccount.java | 26 +++++++ .../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 | 42 ++++++++--- .../fee/response/FeeAccountResponse.java | 16 ++++ .../fee/response/FeeAmountUpdateResponse.java | 7 -- .../fee/response/FeeLinkUpdateResponse.java | 7 -- .../fee/response/FeeStatusUpdateResponse.java | 17 ++++- .../fee/usecase/AdminFeeReviewUseCase.java | 30 ++++++-- .../fee/usecase/AdminFeeSearchUseCase.java | 35 ++++++--- .../welfare/fee/usecase/MemberFeeStatus.java | 10 --- .../stream/api/app/welfare/fee/AppFeeApi.java | 49 ++++++------ .../api/app/welfare/fee/AppFeeController.java | 36 ++++----- .../api/app/welfare/fee/FeeAccount.java | 23 ++++++ .../app/welfare/fee/FeeAccountProvider.java | 39 ++++++++++ .../welfare/fee/FeeTransferUrlProvider.java | 44 ----------- .../fee/request/FeeConfirmRequest.java | 11 --- .../welfare/fee/request/FeeSubmitRequest.java | 18 +++++ .../app/welfare/fee/response/FeeResponse.java | 58 ++++++++++++-- .../welfare/fee/usecase/FeeSubmitUseCase.java | 64 ++++++++++++++++ .../welfare/domain/fee/domain/FeeBanks.java | 26 +++++++ .../domain/fee/domain/FeeErrorCode.java | 15 +++- .../domain/fee/domain/GradeSemester.java | 24 ------ .../fee/domain/StudentTransferStatus.java | 64 +++++++++++----- .../domain/fee/domain/TransferStatus.java | 6 +- .../StudentTransferStatusRepository.java | 6 +- .../fee/service/FeeAmountCalculator.java | 26 +++++-- .../service/StudentTransferStatusService.java | 22 +++++- .../StudentTransferStatusServiceImpl.java | 61 ++++++++++----- .../StudentTransferStatusJpaEntity.java | 29 ++++++- .../StudentTransferStatusJpaRepository.java | 20 ++++- .../StudentTransferStatusRepositoryImpl.java | 30 +++++++- .../V20__add_fee_submission_columns.sql | 12 +++ ...sert_default_student_transfer_statuses.sql | 12 +++ .../V23__rename_needs_check_to_rejected.sql | 5 ++ ...dd_amount_to_student_transfer_statuses.sql | 5 ++ .../V25__remove_unpaid_transfer_statuses.sql | 10 +++ ..._multiple_transfer_requests_per_member.sql | 17 +++++ 44 files changed, 845 insertions(+), 355 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java 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/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 create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeConfirmRequest.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java delete mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/GradeSemester.java create mode 100644 infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql create mode 100644 infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql create mode 100644 infrastructure/db/src/main/resources/db/migration/V23__rename_needs_check_to_rejected.sql create mode 100644 infrastructure/db/src/main/resources/db/migration/V24__add_amount_to_student_transfer_statuses.sql create mode 100644 infrastructure/db/src/main/resources/db/migration/V25__remove_unpaid_transfer_statuses.sql create mode 100644 infrastructure/db/src/main/resources/db/migration/V26__allow_multiple_transfer_requests_per_member.sql diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java new file mode 100644 index 00000000..0586d486 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.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 AdminFeeAccountProvider { + + 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/AdminFeeApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java index 57bfb9fb..73d54cff 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java @@ -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 = "이름·학번 검색과 납부 상태 필터로 납부를 요청한 회원을 조회한다. " + + "한 번도 요청하지 않은 회원은 나오지 않는다. 금액은 요청 시점에 학생에게 안내한 값이다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_TRANSFER_STATUS"}) ApiResponse> getRequests( @@ -41,36 +37,31 @@ 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 = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. " + + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. " + + "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다. " + + "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다.") + @ApiErrorCode(type = FeeErrorCode.class, + codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_NOT_LATEST"}) 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..be3db1ea 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 AdminFeeAccountProvider adminFeeAccountProvider; @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(adminFeeAccountProvider.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 = adminFeeAccountProvider.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..2f4439e6 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.api.admin.welfare.fee; + +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 generateTransferLinkUrl() { + return isConfigured() ? TossTransferLinkGenerator.generate(bank, accountNo, null) : null; + } +} 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..8e5c6c1d 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 = "REJECTED") + 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..cc8f6874 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,30 +2,54 @@ import java.time.LocalDateTime; 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; +/** + * 납부자 목록의 한 줄. 납부를 요청한 회원만 들어온다 — 한 번도 요청하지 않은 회원은 행이 없다. + */ 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 member 탈퇴(소프트 삭제)한 회원의 행이면 null — 이름·학번·학과를 알 수 없어 표시용 placeholder를 쓴다 + * @param signedUp 회원가입(전화번호 + 필수 약관 동의)을 마쳤는지 + */ + public static AdminFeeSearchResponse of( + StudentTransferStatus transferStatus, + Member member, + boolean signedUp + ) { return new AdminFeeSearchResponse( - status.getId(), - status.getMemberId(), + transferStatus.getId(), + transferStatus.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() + signedUp, + transferStatus.getStatus().name(), + transferStatus.getRemainingSemesters(), + // 요청 시점에 남긴 금액이다. 운영진이 학기당 회비를 바꿔도 이미 들어온 요청의 금액은 그대로 보인다 + transferStatus.getAmount(), + StorageUrlBuilder.build(transferStatus.getProofImageKey()), + transferStatus.getRequestedAt(), + // 납부 확인 시각은 납부완료일 때의 처리 시각이다 + 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/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..be558e2e --- /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.generateTransferLinkUrl()); + } +} 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..bb59ae3e 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,38 @@ 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()); + payerService.sync(member.getId(), member.getName(), member.getStudentId(), reviewed.getStatus()); + + return reviewed; + } - return transferStatus; + // 확인필요는 학생에게 보여줄 안내 문구가 있어야 의미가 있다. 그 외 상태에서는 문구를 받아도 버린다 + private void validateAdminNote(TransferStatus status, String adminNote) { + if (status != TransferStatus.REJECTED) { + 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..cc4edc12 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,7 @@ import java.util.List; import java.util.Map; +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,6 +13,16 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +/** + * 납부자 목록. {@code welfare}와 {@code member} 두 도메인을 조합한다. + *

+ * 페이징과 상태 필터는 회비 쪽에서 한다. 납부를 요청한 회원만 행이 있으므로 목록도 요청한 회원만 나온다 + * (한 번도 요청하지 않은 미납부 회원은 관리자 화면에서 다루지 않는다). + *

+ * member 쪽은 이미 공개된 조회 메서드만 쓴다 — 검색은 {@code searchIdsByKeyword}, 회원 정보는 {@code getMapByIds}. + *

+ * 트랜잭션을 걸지 않는다. 쓰기가 없고, 조회 사이가 어긋나도 한 줄의 정보가 잠깐 최신이 아닐 뿐이다. + */ @Component @RequiredArgsConstructor public class AdminFeeSearchUseCase { @@ -19,27 +30,27 @@ public class AdminFeeSearchUseCase { private final StudentTransferStatusService studentTransferStatusService; private final MemberService memberService; - public PageResult search(TransferStatus status, String keyword, PageOffset pageOffset) { + 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(); + PageResult page = studentTransferStatusService.search(status, memberIds, pageOffset); + List pagedMemberIds = page.content().stream().map(StudentTransferStatus::getMemberId).toList(); - // 탈퇴(소프트 삭제)한 회원은 findAllByIds에서 빠지므로, 그 회원의 과거 요청은 member가 null로 남는다. - // (탈퇴 회원의 요청을 조회 자체에서 제외하는 대신, 표시는 Controller/DTO에서 처리한다) - Map memberMap = memberService.getMapByIds(resultMemberIds); + // 탈퇴(소프트 삭제)한 회원은 빠지므로 그 회원의 행은 member가 null로 남는다 + Map membersById = memberService.getMapByIds(pagedMemberIds); - return result.map(transferStatus -> new MemberFeeStatus(transferStatus, memberMap.getOrDefault(transferStatus.getMemberId(), null))); + return page.map(transferStatus -> AdminFeeSearchResponse.of( + transferStatus, + membersById.get(transferStatus.getMemberId()), + // 가입 여부는 member가 아는 사실이라 member의 공개 메서드에 묻는다. + // 줄마다 한 번씩 부르므로 목록이 커지면 벌크 조회를 member 쪽에 요청할 자리다 + memberService.isSignedUp(transferStatus.getMemberId()) + )); } } 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/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java index df7e566e..82b59488 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java @@ -2,41 +2,42 @@ 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.app.AppApiUser; -import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeConfirmRequest; +import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; -import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; /** * 학생 앱 학생회비 API의 문서 명세. 구현은 {@link AppFeeController}가 맡는다. *

- * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 - * 파라미터 바인딩(@{@code RequestParam}, @{@code RequestBody} 등)은 구현체에 둔다. + * 납부는 한 번에 요청한다. 학생은 남은 학기 수로 송금 링크를 받아(조회만, 저장하지 않음) 토스에서 송금한 뒤, + * 학점이수현황 캡처와 함께 납부를 요청한다. 이때 처음 저장되고 바로 납부확인중이 된다. + * 운영진이 확인필요로 돌려보내면 학생은 학기 인증부터 다시 낼 수 있다. + *

+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. */ @Tag(name = "학생회비", description = "학생 앱 학생회비 납부") public interface AppFeeApi { - /** 내 학생회비 납부 상태와 송금 링크. 학년·학기를 지정하면 해당 학기의 송금 링크를 내려준다. */ + /** 내 학생회비 납부 현황. 남은 학기 수를 주면 그 값으로 금액과 송금 링크를 계산한다. */ @Operation(summary = "내 학생회비 상태 조회", - description = "로그인한 학생의 납부 상태와 함께 지정한 학년·학기의 송금 링크를 조회한다.") - @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_TRANSFER_LINK_INVALID"}) - ApiResponse getFeeByMemberId( - AppApiUser apiUser, - Integer grade, - Integer semester - ); + description = "로그인한 학생의 납부 상태와 납부 계좌를 조회한다. 아무것도 저장하지 않는다. " + + "납부 전(UNPAID)이거나 확인필요(REJECTED)인 학생이 remainingSemesters(남은 학기 수, 1~8)를 보내면 " + + "그 값으로 납부 금액과 송금 링크를 계산해 준다. 납부확인중·납부완료면 요청할 때 남긴 금액만 보여주고 " + + "송금 링크는 주지 않는다.") + @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_REMAINING_SEMESTERS", "FEE_ACCOUNT_NOT_FOUND"}) + ApiResponse getFeeByMemberId(AppApiUser apiUser, Integer remainingSemesters); - /** 납부 확인 요청. 송금 후 운영진 확인을 요청하는 상태로 전환한다. */ - @Operation(summary = "학생회비 납부 확인 요청", - description = "송금을 마친 학생이 운영진의 납부 확인을 요청한다.") - @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_TRANSFER_LINK_INVALID"}) - ApiResponse requestConfirmation( - AppApiUser apiUser, - FeeConfirmRequest request - ); + /** 학생회비 납부 요청. 학기 인증과 송금 완료를 한 번에 받아 바로 납부확인중이 된다. */ + @Operation(summary = "학생회비 납부 요청", + description = "토스로 송금을 마친 학생이 남은 학기 수와 학점이수현황 캡처를 함께 보내 운영진의 확인을 요청한다. " + + "요청 시점의 금액(남은 학기 수 × 학기당 회비)을 함께 남긴다. " + + "처음 내는 학생과 확인필요로 돌려받은 학생만 요청할 수 있다. 돌려받은 학생이 다시 내면 새 요청으로 쌓이고 " + + "이전 요청은 그대로 남는다.") + @ApiErrorCode(type = FeeErrorCode.class, + codes = {"INVALID_REMAINING_SEMESTERS", "INVALID_PROOF_IMAGE", "FEE_ACCOUNT_NOT_FOUND", + "FEE_REQUEST_ALREADY_REQUESTED"}) + ApiResponse submit(AppApiUser apiUser, FeeSubmitRequest request); } 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 c156ce44..f651418f 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java @@ -1,11 +1,11 @@ package kr.ac.kookmin.stream.api.app.welfare.fee; -import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.app.AppApiUser; -import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeConfirmRequest; +import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.GradeSemester; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; +import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.FeeSubmitUseCase; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -21,30 +21,30 @@ public class AppFeeController implements AppFeeApi { private final StudentTransferStatusService studentTransferStatusService; - private final FeeTransferUrlProvider feeTransferUrlProvider; + private final FeeAccountProvider feeAccountProvider; + private final FeeSubmitUseCase feeSubmitUseCase; + // 남은 학기 수는 금액과 송금 링크를 계산할 때만 쓴다. 이 조회는 아무것도 저장하지 않는다 @Override @GetMapping("/me") public ApiResponse getFeeByMemberId( AppApiUser apiUser, - @RequestParam(required = false) Integer grade, - @RequestParam(required = false) Integer semester + @RequestParam(required = false) Integer remainingSemesters ) { - GradeSemester gradeSemester = GradeSemester.of(grade, semester); - StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId()); + Integer semesters = remainingSemesters == null + ? null + : FeeAmountCalculator.validateRemainingSemesters(remainingSemesters); + FeeAccount account = feeAccountProvider.getAccount(); - return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getTransferUrl(gradeSemester))); + FeeResponse response = studentTransferStatusService.findLatestByMemberId(apiUser.userId()) + .map(transferStatus -> FeeResponse.of(transferStatus, account, semesters)) + .orElseGet(() -> FeeResponse.notRequested(account, semesters)); + return ApiResponse.success(response); } @Override @PostMapping - public ApiResponse requestConfirmation( - AppApiUser apiUser, - @RequestBody FeeConfirmRequest request - ) { - GradeSemester gradeSemester = request.toGradeSemester(); - StudentTransferStatus transferStatus = studentTransferStatusService.requestConfirmation(apiUser.userId()); - - return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getTransferUrl(gradeSemester))); + public ApiResponse submit(AppApiUser apiUser, @RequestBody FeeSubmitRequest request) { + return ApiResponse.success(feeSubmitUseCase.submit(apiUser.userId(), request)); } } 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 new file mode 100644 index 00000000..5d7a2f3e --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.api.app.welfare.fee; + +import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; +import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator; + +/** + * 관리자가 설정한 회비 납부 설정. 셋 다 {@code admin_config_values}에 들어 있고 같이 읽힌다. + * 계좌는 전역 하나이며, 학생마다 다른 것은 금액뿐이다. + * + * @param amountPerSemester 학기당 회비 + */ +public record FeeAccount(String bank, String accountNo, long amountPerSemester) { + + /** 이 학생이 보낼 금액. 남은 학기 수 × 학기당 회비. */ + public long calculateTransferAmount(int remainingSemesters) { + return FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester); + } + + /** 금액이 붙은 토스 송금 링크. */ + 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/FeeAccountProvider.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java new file mode 100644 index 00000000..85c2f6c8 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.api.app.welfare.fee; + +import kr.ac.kookmin.stream.common.BusinessException; +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 kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 회비 납부 설정을 읽어 온다. + *

+ * 계좌와 학기당 금액을 조회 시점에 함께 읽는다 — 계좌만 바뀌거나 금액만 바뀌어도 항상 최신 값을 + * 반영하기 위함(둘 다 admin_config_values에서 따로 관리, FeeConfigKeys 참고). + * welfare 도메인은 internal 모듈에 의존할 수 없어 이 조회를 api에 둔다. + */ +@Component +@RequiredArgsConstructor +public class FeeAccountProvider { + + private final AdminConfigValueService adminConfigValueService; + + /** 계좌가 설정되지 않았으면 송금 자체가 불가능하므로 막는다. */ + public FeeAccount getAccount() { + String bank = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null); + String accountNo = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null); + + if (bank == null || accountNo == null) { + throw new BusinessException(FeeErrorCode.FEE_ACCOUNT_NOT_FOUND); + } + + long amountPerSemester = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT) + .map(Long::parseLong) + .orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT); + + return new FeeAccount(bank, accountNo, amountPerSemester); + } +} 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/FeeTransferUrlProvider.java deleted file mode 100644 index 32e2cd2e..00000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java +++ /dev/null @@ -1,44 +0,0 @@ -package kr.ac.kookmin.stream.api.app.welfare.fee; - -import kr.ac.kookmin.stream.common.BusinessException; -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 kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.GradeSemester; -import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; -import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -/** - * 회비 송금 링크 생성을 담당하는 파사드. - *

- * 계좌(은행/계좌번호)와 학기당 금액을 조회 시점에 조합해 링크를 만든다 — 계좌만 바뀌거나 금액만 바뀌어도 - * 항상 최신 값을 반영하기 위함(둘 다 admin_config_values에서 따로 관리, FeeConfigKeys 참고). - * 실제 송금액은 남은 학기 수만큼만 걷는다(FeeAmountCalculator). - */ -@Component -@RequiredArgsConstructor -public class FeeTransferUrlProvider { - - private final AdminConfigValueService adminConfigValueService; - - // 계좌가 아직 설정되지 않았으면 링크를 만들 수 없으므로 예외를 던진다. - public String getTransferUrl(GradeSemester gradeSemester) { - String bank = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null); - String accountNo = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null); - - if (bank == null || accountNo == null) { - throw new BusinessException(FeeErrorCode.FEE_TRANSFER_LINK_INVALID); - } - - long amountPerSemester = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT) - .map(Long::parseLong) - .orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT); - - long amount = FeeAmountCalculator.calculate(gradeSemester, amountPerSemester); - - return TossTransferLinkGenerator.generate(bank, accountNo, amount); - } -} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeConfirmRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeConfirmRequest.java deleted file mode 100644 index 52b82f3e..00000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeConfirmRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package kr.ac.kookmin.stream.api.app.welfare.fee.request; - -import kr.ac.kookmin.stream.welfare.domain.fee.domain.GradeSemester; - -// 학년·학기 검증은 GradeSemester.of에서 하므로, 여기서는 Bean Validation을 붙이지 않는다. -public record FeeConfirmRequest(Integer grade, Integer semester) { - - public GradeSemester toGradeSemester() { - return GradeSemester.of(grade, semester); - } -} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java new file mode 100644 index 00000000..da4f9db8 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.api.app.welfare.fee.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 학생회비 납부 요청. 송금을 마친 뒤 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 함께 보낸다. + *

+ * 검증에 Bean Validation을 쓰지 않는다 — 명세가 항목마다 전용 에러 코드를 요구하는데 + * 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(coding-style 검증 규칙, fee-domain-study 08번 문서). + */ +public record FeeSubmitRequest( + @Schema(description = "남은 학기 수 (1~8)", example = "6") + Integer remainingSemesters, + + @Schema(description = "업로드 URL 발급 때 받은 이미지 키", example = "files/3f2a9c1e-7b4d-4e8a-9c2f-1a2b3c4d5e6f.png") + String proofImageKey +) { +} 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 757331df..7f035dfc 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 @@ -1,18 +1,66 @@ package kr.ac.kookmin.stream.api.app.welfare.fee.response; import java.time.LocalDateTime; +import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; +/** + * 학생회비 납부 현황. 화면은 feeStatus로 분기한다 — 납부 전(UNPAID)·확인필요(REJECTED)면 남은 학기 입력부터, + * 납부확인중·납부완료면 요청 내용 확인 화면이다. + *

+ * 금액과 송금 링크는 지금 낼 수 있을 때만 계산해 준다. 납부 전·확인필요인 학생이 남은 학기 수를 보내면 그 값으로 + * 계산하고, 납부확인중·납부완료면 요청할 때 남긴 금액만 보여주고 링크는 주지 않는다("또 보내라"로 읽히지 않게). + */ public record FeeResponse( + Long studentTransferStatusId, String feeStatus, + String bank, + String accountNo, + Integer remainingSemesters, + Long transferAmount, String transferLinkUrl, LocalDateTime requestedAt, - LocalDateTime confirmedAt + LocalDateTime confirmedAt, + String adminNote ) { - public static FeeResponse of(StudentTransferStatus request, String transferLinkUrl) { - LocalDateTime confirmedAt = request.getStatus() == TransferStatus.PAID ? request.getReviewedAt() : null; - // student_transfer_statuses엔 별도 requested_at 컬럼이 없어, 확인 요청 시각은 row의 created_at으로 갈음한다. - return new FeeResponse(request.getStatus().name(), transferLinkUrl, request.getCreatedAt(), confirmedAt); + + // 한 번도 요청하지 않은 회원의 상태. 도메인 상태에는 없고(행이 없다) 응답에서만 쓰는 이름이다 + private static final String NOT_REQUESTED_STATUS = "UNPAID"; + + /** 한 번도 요청하지 않은 학생. 남은 학기 수를 받았으면 그 값으로 금액과 송금 링크를 계산한다. */ + public static FeeResponse notRequested(FeeAccount account, Integer remainingSemesters) { + Long transferAmount = amountOf(account, remainingSemesters); + + return new FeeResponse(null, NOT_REQUESTED_STATUS, account.bank(), account.accountNo(), + remainingSemesters, transferAmount, linkOf(account, transferAmount), null, null, null); + } + + /** 요청이 있는 학생. remainingSemesters는 확인필요(다시 낼 수 있는 상태)일 때만 쓴다. */ + public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account, Integer remainingSemesters) { + TransferStatus status = transferStatus.getStatus(); + + // 반려면 다시 낼 수 있으므로 미납부와 같이 다룬다 — 이전 요청 값 대신 이번에 받은 남은 학기 수로 계산한다 + if (status == TransferStatus.REJECTED) { + Long transferAmount = amountOf(account, remainingSemesters); + return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), + remainingSemesters, transferAmount, linkOf(account, transferAmount), + transferStatus.getRequestedAt(), null, transferStatus.getAdminNote()); + } + + return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), + transferStatus.getRemainingSemesters(), transferStatus.getAmount(), null, + transferStatus.getRequestedAt(), + // 납부 확인 시각은 PAID일 때의 처리 시각이다 + status == TransferStatus.PAID ? transferStatus.getReviewedAt() : null, + null); + } + + private static Long amountOf(FeeAccount account, Integer remainingSemesters) { + return remainingSemesters == null ? null : account.calculateTransferAmount(remainingSemesters); + } + + private static String linkOf(FeeAccount account, Long transferAmount) { + return transferAmount == null ? null : 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 new file mode 100644 index 00000000..e0351c6a --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java @@ -0,0 +1,64 @@ +package kr.ac.kookmin.stream.api.app.welfare.fee.usecase; + +import java.util.Objects; +import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount; +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.FeeResponse; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.file.domain.FileCategory; +import kr.ac.kookmin.stream.file.service.FileService; +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.service.FeeAmountCalculator; +import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 학생회비 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 한 번에 받는다. + * {@code welfare}(납부 요청)와 {@code file}(인증 이미지) 두 도메인을 조합한다. + *

+ * 메서드에 {@code @Transactional}을 붙이지 않는다. 쓰기는 {@code StudentTransferStatusService.request} 하나뿐이고 + * 그 트랜잭션 안에서 끝난다. 인증 이미지는 조회해서 검증만 한다. + *

+ * 다시 내도 이전 인증 이미지를 지우지 않는다. 반려된 이전 요청이 이력으로 남아 그 이미지를 계속 가리킨다. + */ +@Component +@RequiredArgsConstructor +public class FeeSubmitUseCase { + + private final StudentTransferStatusService studentTransferStatusService; + private final FeeAccountProvider feeAccountProvider; + private final FileService fileService; + + public FeeResponse submit(Long memberId, FeeSubmitRequest request) { + int remainingSemesters = FeeAmountCalculator.validateRemainingSemesters(request.remainingSemesters()); + validateProofImage(request.proofImageKey(), memberId); + + // 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다 + FeeAccount account = feeAccountProvider.getAccount(); + long amount = account.calculateTransferAmount(remainingSemesters); + + StudentTransferStatus saved = + studentTransferStatusService.request(memberId, remainingSemesters, request.proofImageKey(), amount); + return FeeResponse.of(saved, account, null); + } + + // 실재하는 키인지, 본인이 올린 것인지, 학점이수현황 캡처로 올린 것인지 본다. + // 카테고리까지 보는 이유는 학생이 다른 용도로 올려둔 파일의 키를 넘기는 걸 막기 위해서다. + private void validateProofImage(String proofImageKey, Long memberId) { + if (proofImageKey == null || proofImageKey.isBlank()) { + throw new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE); + } + + File proofImage = fileService.findByFileKey(proofImageKey) + .orElseThrow(() -> new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE)); + + if (!Objects.equals(proofImage.getUploaderId(), memberId) + || proofImage.getCategory() != FileCategory.FEE_TRANSCRIPT_IMAGE) { + throw new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE); + } + } +} 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 e3eb793f..4dd64f3e 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java @@ -11,11 +11,18 @@ @AllArgsConstructor public enum FeeErrorCode implements ErrorCode { - // 상태 문자열이 UNPAID/PENDING/PAID가 아닌 경우와, 처리 상태로 PENDING을 지정한 경우를 함께 쓴다 + // 상태 문자열이 PENDING/PAID/REJECTED가 아닌 경우에 쓴다 INVALID_TRANSFER_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 납부 상태입니다."), - FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 납부 확인 요청입니다."), - FEE_REQUEST_ALREADY_REVIEWED(ErrorStatus.CONFLICT, "이미 처리된 납부 확인 요청입니다."), - FEE_TRANSFER_LINK_INVALID(ErrorStatus.INTERNAL_SERVER_ERROR, "송금 요청 URL이 생성되지 않았습니다. 관리자에게 문의해 주세요."), + 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_NOT_LATEST(ErrorStatus.CONFLICT, "회원의 최신 납부 요청만 처리할 수 있습니다."), MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다."); private final int status; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/GradeSemester.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/GradeSemester.java deleted file mode 100644 index 8410090f..00000000 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/GradeSemester.java +++ /dev/null @@ -1,24 +0,0 @@ -package kr.ac.kookmin.stream.welfare.domain.fee.domain; - -/** - * 학년(1~4)·학기(1~2)를 묶은 값 객체. 항상 유효한 상태로만 생성되도록 {@link #of}에서 검증한다. - */ -public record GradeSemester(int grade, int semester) { - - private static final int MIN_GRADE = 1; - private static final int MAX_GRADE = 4; - private static final int MIN_SEMESTER = 1; - private static final int MAX_SEMESTER = 2; - - // 값이 없거나(null) 범위를 벗어나면 IllegalArgumentException을 던진다. - // (웹 계층에서 파라미터로 받으므로 미입력까지 여기서 함께 걸러낸다.) - public static GradeSemester of(Integer grade, Integer semester) { - if (grade == null || grade < MIN_GRADE || grade > MAX_GRADE) { - throw new IllegalArgumentException("학년은 " + MIN_GRADE + "~" + MAX_GRADE + " 사이여야 합니다."); - } - if (semester == null || semester < MIN_SEMESTER || semester > MAX_SEMESTER) { - throw new IllegalArgumentException("학기는 " + MIN_SEMESTER + "~" + MAX_SEMESTER + " 사이여야 합니다."); - } - return new GradeSemester(grade, semester); - } -} 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 cc391cfa..213d7147 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.welfare.domain.fee.domain; import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -14,44 +15,69 @@ public class StudentTransferStatus { private Long id; private Long memberId; private TransferStatus status; + // 학생이 입력한 남은 학기 수와, 그 근거로 올린 학점이수현황 캡처 + private Integer remainingSemesters; + private String proofImageKey; + // 요청 시점에 안내한 납부 금액(남은 학기 수 × 그때의 학기당 회비). 운영진이 학기당 회비를 바꿔도 그대로다 + private Long amount; + // 관리자가 확인필요로 처리하며 남긴 안내 문구. REJECTED일 때만 값이 있다 + private String adminNote; + private LocalDateTime requestedAt; private LocalDateTime reviewedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; - public static StudentTransferStatus create(Long memberId) { - return new StudentTransferStatus(null, memberId, TransferStatus.PENDING, null, null, null); - } - - // 아직 확인 요청을 한 번도 보내지 않은 회원을 위한 가상의 기본값 (DB에 row가 없음, 저장하지 않는다) - public static StudentTransferStatus notRequested(Long memberId) { - return new StudentTransferStatus(null, memberId, TransferStatus.UNPAID, null, null, null); + /** + * 송금을 마친 학생의 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 한 번에 받으므로 + * 바로 납부확인중이 된다. 처음 내든 반려 뒤 다시 내든 늘 새 요청(행)이고, 이전 요청은 이력으로 남는다. + */ + public static StudentTransferStatus request( + Long memberId, + int remainingSemesters, + String proofImageKey, + long amount, + LocalDateTime now + ) { + return new StudentTransferStatus(null, memberId, TransferStatus.PENDING, remainingSemesters, proofImageKey, + amount, null, now, null, null, null); } public static StudentTransferStatus of( Long id, Long memberId, TransferStatus status, + Integer remainingSemesters, + String proofImageKey, + Long amount, + String adminNote, + LocalDateTime requestedAt, LocalDateTime reviewedAt, LocalDateTime createdAt, LocalDateTime updatedAt ) { - return new StudentTransferStatus(id, memberId, status, reviewedAt, createdAt, updatedAt); + return new StudentTransferStatus(id, memberId, status, remainingSemesters, proofImageKey, + amount, adminNote, requestedAt, reviewedAt, createdAt, updatedAt); } - // 확인 요청(POST /v1/app/fee) 시각은 별도 컬럼 없이 row의 created_at(최초 생성) 또는 - // 재요청 시점의 updated_at으로 갈음한다 — student_transfer_statuses 테이블에 requested_at 컬럼을 두지 않기로 했다(ERD 기준). - // PAID는 최종 상태다 — 이미 승인된 회원이 다시 요청해도 상태를 되돌리지 않고 그대로 둔다. - public void requestConfirmation() { - if (status == TransferStatus.PAID) { - return; + /** + * 이 요청 다음에 새 납부 요청을 받을 수 있는지 본다. 반려된 요청 뒤에만 다시 낼 수 있다 — + * 납부확인중이면 이미 운영진에게 요청이 가 있고, 납부완료면 더 낼 것이 없다. + */ + public void validateRequestable() { + if (status != TransferStatus.REJECTED) { + throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED); } - - this.status = TransferStatus.PENDING; - 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.REJECTED ? adminNote : null; + this.reviewedAt = reviewedStatus == TransferStatus.PENDING ? null : now; } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java index be08d99c..62cafa66 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java @@ -2,10 +2,12 @@ import kr.ac.kookmin.stream.common.BusinessException; +// 납부 요청의 상태. 한 번도 요청하지 않은 회원(미납부)은 행이 없으므로 여기에 상태가 없다 public enum TransferStatus { - UNPAID, PENDING, - PAID; + PAID, + // 운영진이 납부를 확인하지 못해 돌려보낸 상태. 화면에서는 "확인필요"로 보인다 + REJECTED; // Spring MVC는 잘못된 enum 문자열을 MethodArgumentNotValidException이 아닌 다른 예외로 던지므로 // 컨트롤러가 String으로 받아 이 메서드로 변환한다. null(상태 필터 미지정)은 그대로 null을 돌려준다. diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java index d4596599..87adf5c5 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java @@ -7,11 +7,13 @@ import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; +// 납부 요청은 이력으로 쌓이므로 회원 기준 조회는 모두 최신 요청(PK가 가장 큰 행) 하나만 돌려준다 public interface StudentTransferStatusRepository { - Optional findByMemberId(Long memberId); + Optional findLatestByMemberId(Long memberId); Optional findById(Long id); + List findLatestByMemberIds(List memberIds); - // memberIds가 null이면 전체 대상, 빈 리스트면 결과 없음을 의도한 것이므로 호출 전에 걸러낸다(빈 리스트로 호출하지 않는다) + // 회원별 최신 요청만 대상으로 한다. memberIds가 null이면 전체 대상, 빈 리스트면 결과 없음을 의도한 것이므로 호출 전에 걸러낸다(빈 리스트로 호출하지 않는다) PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); StudentTransferStatus save(StudentTransferStatus transferStatus); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java index af2ef180..88def836 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java @@ -1,18 +1,30 @@ package kr.ac.kookmin.stream.welfare.domain.fee.service; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.GradeSemester; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; -// 4년(8학기)치 회비를 남은 학기 수만큼만 걷는다 — (전체 8학기 - 이수완료학기) * 학기당 금액. -// 이수완료학기는 학년·학기로 계산한다: 1학년 1학기면 0학기 이수, 2학년 1학기면 2학기 이수하는 식. +// 남은 학기 수만큼만 회비를 걷는다 — 남은 학기 수 × 학기당 금액. +// 남은 학기 수는 학생이 직접 입력하고 학점이수현황 캡처로 증빙한다(예전에는 학년·학기로 계산했다). public final class FeeAmountCalculator { - private static final int TOTAL_SEMESTERS = 8; + private static final int MIN_REMAINING_SEMESTERS = 1; + private static final int MAX_REMAINING_SEMESTERS = 8; private FeeAmountCalculator() {} - public static long calculate(GradeSemester gradeSemester, long amountPerSemester) { - int completedSemesters = (gradeSemester.grade() - 1) * 2 + (gradeSemester.semester() - 1); - int remainingSemesters = Math.max(TOTAL_SEMESTERS - completedSemesters, 0); + /** + * 남은 학기 수가 1~8인지 본다. 송금 링크를 받을 때와 납부를 요청할 때 같은 범위를 쓰도록 여기에 둔다. + */ + public static int validateRemainingSemesters(Integer remainingSemesters) { + if (remainingSemesters == null + || remainingSemesters < MIN_REMAINING_SEMESTERS + || remainingSemesters > MAX_REMAINING_SEMESTERS) { + throw new BusinessException(FeeErrorCode.INVALID_REMAINING_SEMESTERS); + } + return remainingSemesters; + } + + public static long calculate(int remainingSemesters, long amountPerSemester) { return (long) remainingSemesters * amountPerSemester; } } 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 840a678e..a9671aaa 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java @@ -1,6 +1,8 @@ package kr.ac.kookmin.stream.welfare.domain.fee.service; import java.util.List; +import java.util.Map; +import java.util.Optional; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; @@ -8,12 +10,26 @@ public interface StudentTransferStatusService { - StudentTransferStatus getByMemberId(Long memberId); + /** 회원의 최신 납부 요청 — 곧 회원의 지금 상태다. 한 번도 요청하지 않은 회원(미납부)이면 비어 있다. */ + Optional findLatestByMemberId(Long memberId); - StudentTransferStatus requestConfirmation(Long memberId); + /** + * 학기 인증과 송금 완료를 한 번에 받아 납부확인중인 새 요청을 만든다. 처음이든 반려 뒤든 늘 새 행이고, + * 이전 요청은 그대로 남는다. 최신 요청이 납부확인중·납부완료면 {@code FEE_REQUEST_ALREADY_REQUESTED}다. + * + * @param amount 요청 시점에 안내한 납부 금액 + */ + StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount); + + /** 회원별 최신 납부 요청. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 한 번도 요청하지 않은 회원은 빠진다. */ + Map getMapByMemberIds(List memberIds); // status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음 PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); - StudentTransferStatus review(Long transferStatusId, TransferStatus status); + /** + * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다. + * 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다. + */ + StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java index fb29f8e7..615f52fa 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java @@ -2,7 +2,12 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Optional; +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; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; @@ -20,25 +25,36 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService { private final StudentTransferStatusRepository studentTransferStatusRepository; + // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다). @Override - @Transactional(readOnly = true) - public StudentTransferStatus getByMemberId(Long memberId) { - return studentTransferStatusRepository.findByMemberId(memberId) - .orElseGet(() -> StudentTransferStatus.notRequested(memberId)); + public Optional findLatestByMemberId(Long memberId) { + return studentTransferStatusRepository.findLatestByMemberId(memberId); + } + + @Override + public Map getMapByMemberIds(List memberIds) { + if (memberIds.isEmpty()) { + return Map.of(); + } + return studentTransferStatusRepository.findLatestByMemberIds(memberIds).stream() + .collect(Collectors.toMap(StudentTransferStatus::getMemberId, Function.identity())); } @Override @Transactional - public StudentTransferStatus requestConfirmation(Long memberId) { - StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId) - .map(existing -> { - existing.requestConfirmation(); - return existing; - }) - .orElseGet(() -> StudentTransferStatus.create(memberId)); + public StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount) { + // 반려된 요청 뒤에만 새 요청을 받는다. 이전 요청은 고치지 않고 이력으로 남긴다 + studentTransferStatusRepository.findLatestByMemberId(memberId) + .ifPresent(StudentTransferStatus::validateRequestable); + + // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V26)가 두 번째 저장을 막는다 + StudentTransferStatus transferStatus = StudentTransferStatus.request( + memberId, remainingSemesters, proofImageKey, amount, LocalDateTime.now(DateUtil.KST)); return studentTransferStatusRepository.save(transferStatus); } + // 여기는 readOnly를 유지한다. 페이지 조회는 목록과 전체 개수를 두 번에 나눠 읽으므로, + // 한 트랜잭션으로 묶어야 그 사이 들어온 쓰기 때문에 totalCount와 content가 어긋나지 않는다. @Override @Transactional(readOnly = true) public PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset) { @@ -47,18 +63,23 @@ 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); - } + requireLatest(transferStatus); - transferStatus.review(status, LocalDateTime.now()); + transferStatus.review(status, adminNote, LocalDateTime.now(DateUtil.KST)); return studentTransferStatusRepository.save(transferStatus); } + + // 지난 요청은 이력이라 바꾸지 않는다. 회원의 지금 상태는 최신 요청 하나뿐이고, 지난 요청을 되살리면 + // 회원당 진행 중 요청이 둘이 될 수 있다 + private void requireLatest(StudentTransferStatus transferStatus) { + boolean latest = studentTransferStatusRepository.findLatestByMemberId(transferStatus.getMemberId()) + .map(found -> found.getId().equals(transferStatus.getId())) + .orElse(false); + if (!latest) { + throw new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_LATEST); + } + } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java index cc6c77be..b6772a83 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java @@ -7,8 +7,8 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import java.time.LocalDateTime; import kr.ac.kookmin.stream.db.common.BaseTimeEntity; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; @@ -20,10 +20,12 @@ * 현재는 학생회장/총무부장 등 소수 인원만 처리 권한을 가져 낙관적 락(@Version)을 쓰지 않는다. * 처리 권한자가 여러 명으로 늘어나면 동시 처리 경합을 막기 위해 @Version 도입을 검토한다. */ +// uk_student_transfer_statuses_active_member_id(V26)는 generated column active_member_id에 걸려 있다. +// Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. @Entity @Table( name = "student_transfer_statuses", - uniqueConstraints = @UniqueConstraint(name = "uk_student_transfer_statuses_member_id", columnNames = "member_id") + indexes = @Index(name = "idx_student_transfer_statuses_member_id_id", columnList = "member_id, student_transfer_status_id") ) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class StudentTransferStatusJpaEntity extends BaseTimeEntity { @@ -40,6 +42,21 @@ public class StudentTransferStatusJpaEntity extends BaseTimeEntity { @Column(nullable = false, length = 20) private TransferStatus status; + @Column(name = "remaining_semesters") + private Integer remainingSemesters; + + @Column(name = "proof_image_key", length = 255) + private String proofImageKey; + + @Column(name = "amount") + private Long amount; + + @Column(name = "admin_note", length = 255) + private String adminNote; + + @Column(name = "requested_at") + private LocalDateTime requestedAt; + @Column(name = "reviewed_at") private LocalDateTime reviewedAt; @@ -47,6 +64,11 @@ private StudentTransferStatusJpaEntity(StudentTransferStatus request) { this.id = request.getId(); this.memberId = request.getMemberId(); this.status = request.getStatus(); + this.remainingSemesters = request.getRemainingSemesters(); + this.proofImageKey = request.getProofImageKey(); + this.amount = request.getAmount(); + this.adminNote = request.getAdminNote(); + this.requestedAt = request.getRequestedAt(); this.reviewedAt = request.getReviewedAt(); } @@ -55,6 +77,7 @@ public static StudentTransferStatusJpaEntity from(StudentTransferStatus request) } public StudentTransferStatus toDomain() { - return StudentTransferStatus.of(id, memberId, status, reviewedAt, getCreatedAt(), getUpdatedAt()); + return StudentTransferStatus.of(id, memberId, status, remainingSemesters, proofImageKey, + amount, adminNote, requestedAt, reviewedAt, getCreatedAt(), getUpdatedAt()); } } 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..dc19c3cd 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java @@ -9,19 +9,33 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +/** + * 납부 요청은 이력으로 쌓인다. 회원의 지금 상태는 PK가 가장 큰 행이다 — 같은 회원에게 반려(REJECTED)가 여러 개 + * 쌓이고 최신 행 자체가 반려일 수도 있어 상태로는 고를 수 없고, requested_at은 초 단위라 같은 초에 겹칠 수 있다. + */ public interface StudentTransferStatusJpaRepository extends JpaRepository { - Optional findByMemberId(Long memberId); + Optional findFirstByMemberIdOrderByIdDesc(Long memberId); + @Query(""" + SELECT r FROM StudentTransferStatusJpaEntity r + WHERE r.memberId IN :memberIds + AND r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId) + """) + List findLatestByMemberIdIn(@Param("memberIds") List memberIds); + + // 회원별 최신 요청만 대상으로 한다. 상태 필터도 최신 요청의 상태로 건다 @Query(value = """ SELECT r FROM StudentTransferStatusJpaEntity r - WHERE (:status IS NULL OR r.status = :status) + WHERE r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId) + AND (:status IS NULL OR r.status = :status) AND (:filterByMember = false OR r.memberId IN :memberIds) ORDER BY r.createdAt DESC, r.id DESC """, countQuery = """ SELECT COUNT(r) FROM StudentTransferStatusJpaEntity r - WHERE (:status IS NULL OR r.status = :status) + WHERE r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId) + AND (:status IS NULL OR r.status = :status) AND (:filterByMember = false OR r.memberId IN :memberIds) """) Page search( diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java index da849279..52051f76 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java @@ -2,12 +2,16 @@ import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.repository.StudentTransferStatusRepository; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @@ -16,11 +20,21 @@ @RequiredArgsConstructor public class StudentTransferStatusRepositoryImpl implements StudentTransferStatusRepository { + private static final String ACTIVE_REQUEST_UNIQUE_CONSTRAINT = "uk_student_transfer_statuses_active_member_id"; + private final StudentTransferStatusJpaRepository studentTransferStatusJpaRepository; @Override - public Optional findByMemberId(Long memberId) { - return studentTransferStatusJpaRepository.findByMemberId(memberId).map(StudentTransferStatusJpaEntity::toDomain); + public Optional findLatestByMemberId(Long memberId) { + return studentTransferStatusJpaRepository.findFirstByMemberIdOrderByIdDesc(memberId) + .map(StudentTransferStatusJpaEntity::toDomain); + } + + @Override + public List findLatestByMemberIds(List memberIds) { + return studentTransferStatusJpaRepository.findLatestByMemberIdIn(memberIds).stream() + .map(StudentTransferStatusJpaEntity::toDomain) + .toList(); } @Override @@ -47,6 +61,16 @@ public PageResult search(TransferStatus status, List Date: Sun, 25 Oct 2026 21:25:45 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EB=A1=9C=20=EB=82=A9=EB=B6=80=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EA=B0=80=20=EB=B0=94=EB=80=8C=EB=A9=B4=20=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=B0=95=EC=8A=A4=EC=97=90=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fee/usecase/AdminFeeReviewUseCase.java | 1 + .../stream/common/event/MemberFeePaid.java | 10 ++++++++ .../event/MemberFeePaymentCanceled.java | 10 ++++++++ .../service/StudentTransferStatusService.java | 2 ++ .../StudentTransferStatusServiceImpl.java | 24 +++++++++++++++++-- 5 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java 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 bb59ae3e..a4269b61 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 @@ -14,6 +14,7 @@ /** * 납부 상태 처리. 상태 변경과 납부자 명부 반영이 어긋나면 안 되므로 한 트랜잭션으로 묶는다. + * 서비스가 남기는 아웃박스 이벤트(납부 여부 변경)도 이 트랜잭션에 함께 커밋·롤백된다. */ @Component @RequiredArgsConstructor diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java new file mode 100644 index 00000000..22e080c9 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.common.event; + +import java.time.LocalDateTime; + +/** + * 회원의 학생회비 납부가 확인되었다 — 운영진이 회원의 최신 납부 요청을 납부완료로 처리했다. + * occurredAt은 같은 회원의 납부·납부 취소가 재시도로 순서가 뒤바뀌어 도착할 때 늦은 쪽을 가려내는 데 쓴다. + */ +public record MemberFeePaid(Long memberId, Long studentTransferStatusId, LocalDateTime occurredAt) + implements DomainEvent {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java new file mode 100644 index 00000000..e502402b --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.common.event; + +import java.time.LocalDateTime; + +/** + * 운영진이 회원의 납부완료를 정정했다 — 납부완료였던 최신 요청을 확인필요·납부확인중으로 바꿨다. + * occurredAt의 쓰임은 {@link MemberFeePaid}와 같다. + */ +public record MemberFeePaymentCanceled(Long memberId, Long studentTransferStatusId, LocalDateTime occurredAt) + implements DomainEvent {} 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 a9671aaa..41744370 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 @@ -30,6 +30,8 @@ public interface StudentTransferStatusService { /** * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다. * 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다. + * 납부 여부가 바뀌면(납부완료가 되거나 납부완료에서 벗어나면) 같은 트랜잭션에서 아웃박스에 + * {@code MemberFeePaid} · {@code MemberFeePaymentCanceled}를 남긴다. */ 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 615f52fa..48b43c58 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 @@ -8,8 +8,11 @@ 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.OutboxWriter; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.common.event.MemberFeePaid; +import kr.ac.kookmin.stream.common.event.MemberFeePaymentCanceled; 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; @@ -24,6 +27,7 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService { private final StudentTransferStatusRepository studentTransferStatusRepository; + private final OutboxWriter outboxWriter; // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다). @Override @@ -68,8 +72,24 @@ public StudentTransferStatus review(Long transferStatusId, TransferStatus status .orElseThrow(() -> new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND)); requireLatest(transferStatus); - transferStatus.review(status, adminNote, LocalDateTime.now(DateUtil.KST)); - return studentTransferStatusRepository.save(transferStatus); + boolean wasPaid = transferStatus.getStatus() == TransferStatus.PAID; + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + transferStatus.review(status, adminNote, now); + StudentTransferStatus reviewed = studentTransferStatusRepository.save(transferStatus); + + recordPaidChange(wasPaid, reviewed, now); + return reviewed; + } + + // 회원의 납부 여부(최신 요청이 PAID인가)가 바뀔 때만 남긴다. 최신 요청만 처리하므로 이 요청의 변화가 곧 회원의 변화다. + // 상태 변경과 같은 트랜잭션에서 기록해서, 처리가 롤백되면 이벤트도 함께 사라진다(OutboxWriter는 트랜잭션 밖에서 부르면 실패한다). + private void recordPaidChange(boolean wasPaid, StudentTransferStatus reviewed, LocalDateTime now) { + boolean paid = reviewed.getStatus() == TransferStatus.PAID; + if (!wasPaid && paid) { + outboxWriter.record(new MemberFeePaid(reviewed.getMemberId(), reviewed.getId(), now)); + } else if (wasPaid && !paid) { + outboxWriter.record(new MemberFeePaymentCanceled(reviewed.getMemberId(), reviewed.getId(), now)); + } } // 지난 요청은 이력이라 바꾸지 않는다. 회원의 지금 상태는 최신 요청 하나뿐이고, 지난 요청을 되살리면 From 3d4c93d603e5c7cd4c3eb51d3f837395b55dc84c 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: Mon, 26 Oct 2026 22:59:49 +0900 Subject: [PATCH 04/18] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EA=B2=80=EC=A6=9D=EC=9D=84=20Bean=20Valid?= =?UTF-8?q?ation=EC=9C=BC=EB=A1=9C=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/fee/AdminFeeAccountProvider.java | 5 ++- .../api/admin/welfare/fee/AdminFeeApi.java | 7 ++-- .../admin/welfare/fee/AdminFeeController.java | 7 ++-- .../welfare/fee/FeeAccountValidator.java | 39 ------------------- .../fee/request/FeeAccountUpdateRequest.java | 12 ++++-- .../fee/request/FeeStatusUpdateRequest.java | 10 +++-- .../fee/usecase/AdminFeeReviewUseCase.java | 16 -------- .../stream/api/app/welfare/fee/AppFeeApi.java | 12 ++++-- .../api/app/welfare/fee/AppFeeController.java | 19 ++++----- .../welfare/fee/request/FeeAmountParams.java | 14 +++++++ .../welfare/fee/request/FeeSubmitRequest.java | 11 ++++-- .../welfare/fee/usecase/FeeSubmitUseCase.java | 12 ++---- .../domain/fee/domain/FeeErrorCode.java | 5 +-- .../fee/domain/StudentTransferStatus.java | 7 +++- .../fee/service/FeeAmountCalculator.java | 20 +--------- .../service/StudentTransferStatusService.java | 3 +- 16 files changed, 77 insertions(+), 122 deletions(-) delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeAmountParams.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java index 0586d486..1d2b30d6 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java @@ -3,6 +3,7 @@ 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.FeeBanks; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -29,8 +30,10 @@ public FeeAccount getAccount() { ); } - // 계좌와 금액은 한 화면에서 같이 저장하므로 세 키를 한 번에 쓴다 + // 계좌와 금액은 한 화면에서 같이 저장하므로 세 키를 한 번에 쓴다. + // 은행명은 토스 송금 링크에 그대로 들어가므로 허용 목록에 있는 은행만 저장한다 public FeeAccount save(String bank, String accountNo, long amount) { + FeeBanks.validate(bank); adminConfigValueService.upsertValues(Map.of( FeeConfigKeys.TRANSFER_BANK, bank, FeeConfigKeys.TRANSFER_ACCOUNT_NO, accountNo, 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 73d54cff..f3c51bc9 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 @@ -43,8 +43,9 @@ ApiResponse> getRequests( + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. " + "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다. " + "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = FeeErrorCode.class, - codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_NOT_LATEST"}) + codes = {"INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_NOT_LATEST"}) ApiResponse updateStatus( AdminApiUser apiUser, Long studentTransferStatusId, @@ -61,7 +62,7 @@ ApiResponse updateStatus( @Operation(summary = "회비 납부 계좌 변경", description = "은행·계좌번호·학기당 회비를 한 번에 저장한다. 은행은 허용 목록 중 하나여야 하고 " + "계좌번호는 하이픈을 제외한 숫자 10~14자리다. 송금 링크는 저장된 값으로 조회 시점에 만들어진다.") - @ApiErrorCode(type = FeeErrorCode.class, - codes = {"INVALID_BANK", "INVALID_ACCOUNT_NO", "INVALID_TRANSFER_AMOUNT"}) + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_BANK"}) 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 be3db1ea..e40c0399 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 @@ -58,10 +58,10 @@ public ApiResponse> getRequests( public ApiResponse updateStatus( AdminApiUser apiUser, @PathVariable Long studentTransferStatusId, - @RequestBody FeeStatusUpdateRequest request + @Valid @RequestBody FeeStatusUpdateRequest request ) { StudentTransferStatus reviewed = adminFeeReviewUseCase.review( - studentTransferStatusId, TransferStatus.from(request.status()), request.adminNote()); + studentTransferStatusId, request.status(), request.adminNote()); return ApiResponse.success(FeeStatusUpdateResponse.from(reviewed)); } @@ -78,9 +78,8 @@ public ApiResponse getAccount(AdminApiUser apiUser) { @RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS) public ApiResponse updateAccount( AdminApiUser apiUser, - @RequestBody FeeAccountUpdateRequest request + @Valid @RequestBody FeeAccountUpdateRequest request ) { - FeeAccountValidator.validate(request); FeeAccount saved = adminFeeAccountProvider.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/FeeAccountValidator.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java deleted file mode 100644 index caac6de9..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java +++ /dev/null @@ -1,39 +0,0 @@ -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 index e46367bc..af513b52 100644 --- 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 @@ -1,21 +1,27 @@ package kr.ac.kookmin.stream.api.admin.welfare.fee.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; /** * 회비 납부 설정 변경. 계좌와 학기당 회비를 한 화면에서 같이 저장하므로 함께 받는다. - *

- * 검증에 Bean Validation을 쓰지 않는다 — 항목마다 전용 에러 코드를 내려줘야 하는데 - * 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(fee-domain-study 08번 문서). */ public record FeeAccountUpdateRequest( @Schema(description = "은행명 (허용 목록 중 하나)", example = "카카오뱅크") + @NotBlank(message = "은행을 선택해 주세요.") String bank, @Schema(description = "계좌번호 (하이픈 제외, 10~14자리)", example = "3333011234567") + @NotBlank(message = "계좌번호를 입력해 주세요.") + @Pattern(regexp = "^\\d{10,14}$", message = "계좌번호는 하이픈 없이 숫자 10~14자리로 입력해 주세요.") String accountNo, @Schema(description = "학기당 학생회비", example = "20000") + @NotNull(message = "학기당 학생회비를 입력해 주세요.") + @Positive(message = "학기당 학생회비는 1원 이상이어야 합니다.") Integer amount ) { } 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 8e5c6c1d..0bbc11fc 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,17 +1,21 @@ package kr.ac.kookmin.stream.api.admin.welfare.fee.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; /** - * 납부 상태 변경. 상태 문자열 파싱은 {@code TransferStatus.from}이 하고, - * 안내 문구는 확인필요일 때만 필수라 Bean Validation 대신 UseCase에서 검증한다. + * 납부 상태 변경. 안내 문구는 확인필요(REJECTED)일 때만 필수라, 그 판단은 도메인이 한다. */ public record FeeStatusUpdateRequest( @Schema(description = "변경할 납부 상태", example = "REJECTED") - String status, + @NotNull(message = "변경할 납부 상태를 입력해 주세요.") + TransferStatus status, @Schema(description = "관리자 안내 문구 (최대 255자). 확인필요일 때 필수", example = "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.") + @Size(max = 255, message = "안내 문구는 255자 이하로 입력해 주세요.") String adminNote ) { } 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 a4269b61..9dfd9051 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,9 +1,7 @@ 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; @@ -20,16 +18,12 @@ @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; @Transactional 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()); @@ -37,14 +31,4 @@ public StudentTransferStatus review(Long transferStatusId, TransferStatus status return reviewed; } - - // 확인필요는 학생에게 보여줄 안내 문구가 있어야 의미가 있다. 그 외 상태에서는 문구를 받아도 버린다 - private void validateAdminNote(TransferStatus status, String adminNote) { - if (status != TransferStatus.REJECTED) { - return; - } - if (adminNote == null || adminNote.isBlank() || adminNote.length() > MAX_ADMIN_NOTE_LENGTH) { - throw new BusinessException(FeeErrorCode.INVALID_ADMIN_NOTE); - } - } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java index 82b59488..5723cabb 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java @@ -3,11 +3,14 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeAmountParams; import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; +import org.springdoc.core.annotations.ParameterObject; /** * 학생 앱 학생회비 API의 문서 명세. 구현은 {@link AppFeeController}가 맡는다. @@ -27,8 +30,9 @@ public interface AppFeeApi { + "납부 전(UNPAID)이거나 확인필요(REJECTED)인 학생이 remainingSemesters(남은 학기 수, 1~8)를 보내면 " + "그 값으로 납부 금액과 송금 링크를 계산해 준다. 납부확인중·납부완료면 요청할 때 남긴 금액만 보여주고 " + "송금 링크는 주지 않는다.") - @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_REMAINING_SEMESTERS", "FEE_ACCOUNT_NOT_FOUND"}) - ApiResponse getFeeByMemberId(AppApiUser apiUser, Integer remainingSemesters); + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_ACCOUNT_NOT_FOUND"}) + ApiResponse getFeeByMemberId(AppApiUser apiUser, @ParameterObject FeeAmountParams params); /** 학생회비 납부 요청. 학기 인증과 송금 완료를 한 번에 받아 바로 납부확인중이 된다. */ @Operation(summary = "학생회비 납부 요청", @@ -36,8 +40,8 @@ public interface AppFeeApi { + "요청 시점의 금액(남은 학기 수 × 학기당 회비)을 함께 남긴다. " + "처음 내는 학생과 확인필요로 돌려받은 학생만 요청할 수 있다. 돌려받은 학생이 다시 내면 새 요청으로 쌓이고 " + "이전 요청은 그대로 남는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = FeeErrorCode.class, - codes = {"INVALID_REMAINING_SEMESTERS", "INVALID_PROOF_IMAGE", "FEE_ACCOUNT_NOT_FOUND", - "FEE_REQUEST_ALREADY_REQUESTED"}) + codes = {"INVALID_PROOF_IMAGE", "FEE_ACCOUNT_NOT_FOUND", "FEE_REQUEST_ALREADY_REQUESTED"}) ApiResponse submit(AppApiUser apiUser, FeeSubmitRequest request); } 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 f651418f..bf516bca 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 @@ -1,18 +1,19 @@ package kr.ac.kookmin.stream.api.app.welfare.fee; +import jakarta.validation.Valid; import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeAmountParams; import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.FeeSubmitUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -27,24 +28,18 @@ public class AppFeeController implements AppFeeApi { // 남은 학기 수는 금액과 송금 링크를 계산할 때만 쓴다. 이 조회는 아무것도 저장하지 않는다 @Override @GetMapping("/me") - public ApiResponse getFeeByMemberId( - AppApiUser apiUser, - @RequestParam(required = false) Integer remainingSemesters - ) { - Integer semesters = remainingSemesters == null - ? null - : FeeAmountCalculator.validateRemainingSemesters(remainingSemesters); + public ApiResponse getFeeByMemberId(AppApiUser apiUser, @Valid @ModelAttribute FeeAmountParams params) { FeeAccount account = feeAccountProvider.getAccount(); FeeResponse response = studentTransferStatusService.findLatestByMemberId(apiUser.userId()) - .map(transferStatus -> FeeResponse.of(transferStatus, account, semesters)) - .orElseGet(() -> FeeResponse.notRequested(account, semesters)); + .map(transferStatus -> FeeResponse.of(transferStatus, account, params.remainingSemesters())) + .orElseGet(() -> FeeResponse.notRequested(account, params.remainingSemesters())); return ApiResponse.success(response); } @Override @PostMapping - public ApiResponse submit(AppApiUser apiUser, @RequestBody FeeSubmitRequest request) { + public ApiResponse submit(AppApiUser apiUser, @Valid @RequestBody FeeSubmitRequest request) { return ApiResponse.success(feeSubmitUseCase.submit(apiUser.userId(), request)); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeAmountParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeAmountParams.java new file mode 100644 index 00000000..2bd3076f --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeAmountParams.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.api.app.welfare.fee.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +/** + * @param remainingSemesters 남은 학기 수. 주면 그 값으로 납부 금액과 송금 링크를 계산하고, 없으면 계산하지 않는다 + */ +public record FeeAmountParams( + @Min(value = 1, message = "남은 학기 수는 1 이상 8 이하여야 합니다.") + @Max(value = 8, message = "남은 학기 수는 1 이상 8 이하여야 합니다.") + Integer remainingSemesters +) { +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java index da4f9db8..ddadecf8 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java @@ -1,18 +1,23 @@ package kr.ac.kookmin.stream.api.app.welfare.fee.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; /** * 학생회비 납부 요청. 송금을 마친 뒤 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 함께 보낸다. - *

- * 검증에 Bean Validation을 쓰지 않는다 — 명세가 항목마다 전용 에러 코드를 요구하는데 - * 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(coding-style 검증 규칙, fee-domain-study 08번 문서). */ public record FeeSubmitRequest( @Schema(description = "남은 학기 수 (1~8)", example = "6") + @NotNull(message = "남은 학기 수를 입력해 주세요.") + @Min(value = 1, message = "남은 학기 수는 1 이상 8 이하여야 합니다.") + @Max(value = 8, message = "남은 학기 수는 1 이상 8 이하여야 합니다.") Integer remainingSemesters, @Schema(description = "업로드 URL 발급 때 받은 이미지 키", example = "files/3f2a9c1e-7b4d-4e8a-9c2f-1a2b3c4d5e6f.png") + @NotBlank(message = "학점이수현황 캡처를 첨부해 주세요.") String proofImageKey ) { } 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 e0351c6a..c6d5f1e9 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 @@ -11,7 +11,6 @@ import kr.ac.kookmin.stream.file.service.FileService; 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.service.FeeAmountCalculator; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -34,25 +33,20 @@ public class FeeSubmitUseCase { private final FileService fileService; public FeeResponse submit(Long memberId, FeeSubmitRequest request) { - int remainingSemesters = FeeAmountCalculator.validateRemainingSemesters(request.remainingSemesters()); validateProofImage(request.proofImageKey(), memberId); // 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다 FeeAccount account = feeAccountProvider.getAccount(); - long amount = account.calculateTransferAmount(remainingSemesters); + long amount = account.calculateTransferAmount(request.remainingSemesters()); - StudentTransferStatus saved = - studentTransferStatusService.request(memberId, remainingSemesters, request.proofImageKey(), amount); + StudentTransferStatus saved = studentTransferStatusService.request( + memberId, request.remainingSemesters(), request.proofImageKey(), amount); return FeeResponse.of(saved, account, null); } // 실재하는 키인지, 본인이 올린 것인지, 학점이수현황 캡처로 올린 것인지 본다. // 카테고리까지 보는 이유는 학생이 다른 용도로 올려둔 파일의 키를 넘기는 걸 막기 위해서다. private void validateProofImage(String proofImageKey, Long memberId) { - if (proofImageKey == null || proofImageKey.isBlank()) { - throw new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE); - } - File proofImage = fileService.findByFileKey(proofImageKey) .orElseThrow(() -> new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE)); 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 4dd64f3e..262366e3 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 @@ -13,12 +13,9 @@ public enum FeeErrorCode implements ErrorCode { // 상태 문자열이 PENDING/PAID/REJECTED가 아닌 경우에 쓴다 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, "유효하지 않은 관리자 안내 문구입니다."), + 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, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."), 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 213d7147..b9ba3a64 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 @@ -73,9 +73,14 @@ public void validateRequestable() { * 관리자가 납부 상태를 바꾼다. 이미 처리한 요청도 다시 바꿀 수 있다 — 잘못 처리한 경우를 되돌리기 위해서다. * 그래서 납부완료도 종결 상태가 아니다. *

- * 안내 문구는 확인필요일 때만 남긴다. 납부확인중으로 되돌리면 아직 처리 전이라는 뜻이므로 처리 시각도 지운다. + * 안내 문구는 확인필요일 때만 남기고, 확인필요면 반드시 있어야 한다 — 학생이 문구를 보고 다시 내기 때문이다. + * 납부확인중으로 되돌리면 아직 처리 전이라는 뜻이므로 처리 시각도 지운다. */ public void review(TransferStatus reviewedStatus, String adminNote, LocalDateTime now) { + if (reviewedStatus == TransferStatus.REJECTED && (adminNote == null || adminNote.isBlank())) { + throw new BusinessException(FeeErrorCode.INVALID_ADMIN_NOTE); + } + this.status = reviewedStatus; this.adminNote = reviewedStatus == TransferStatus.REJECTED ? adminNote : null; this.reviewedAt = reviewedStatus == TransferStatus.PENDING ? null : now; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java index 88def836..33f15477 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java @@ -1,29 +1,11 @@ package kr.ac.kookmin.stream.welfare.domain.fee.service; -import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; - // 남은 학기 수만큼만 회비를 걷는다 — 남은 학기 수 × 학기당 금액. -// 남은 학기 수는 학생이 직접 입력하고 학점이수현황 캡처로 증빙한다(예전에는 학년·학기로 계산했다). +// 남은 학기 수는 학생이 직접 입력하고 학점이수현황 캡처로 증빙한다. public final class FeeAmountCalculator { - private static final int MIN_REMAINING_SEMESTERS = 1; - private static final int MAX_REMAINING_SEMESTERS = 8; - private FeeAmountCalculator() {} - /** - * 남은 학기 수가 1~8인지 본다. 송금 링크를 받을 때와 납부를 요청할 때 같은 범위를 쓰도록 여기에 둔다. - */ - public static int validateRemainingSemesters(Integer remainingSemesters) { - if (remainingSemesters == null - || remainingSemesters < MIN_REMAINING_SEMESTERS - || remainingSemesters > MAX_REMAINING_SEMESTERS) { - throw new BusinessException(FeeErrorCode.INVALID_REMAINING_SEMESTERS); - } - return remainingSemesters; - } - public static long calculate(int remainingSemesters, long amountPerSemester) { return (long) remainingSemesters * amountPerSemester; } 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 41744370..4ce2c64e 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 @@ -28,7 +28,8 @@ public interface StudentTransferStatusService { PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); /** - * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다. + * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남고, + * 확인필요인데 비어 있으면 {@code INVALID_ADMIN_NOTE}다. * 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다. * 납부 여부가 바뀌면(납부완료가 되거나 납부완료에서 벗어나면) 같은 트랜잭션에서 아웃박스에 * {@code MemberFeePaid} · {@code MemberFeePaymentCanceled}를 남긴다. From 59e1fff07cde69dd54735f7a647f1fe15a6c9a23 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: Mon, 26 Oct 2026 22:59:49 +0900 Subject: [PATCH 05/18] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EC=BB=A8=EB=B2=A4=EC=85=98=EC=97=90=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/welfare/fee/response/FeeResponse.java | 13 +++++++------ .../app/welfare/fee/usecase/FeeSubmitUseCase.java | 4 ++-- .../domain/fee/domain/StudentTransferStatus.java | 2 +- .../fee/service/StudentTransferStatusService.java | 2 +- .../impl/StudentTransferStatusServiceImpl.java | 5 ++--- 5 files changed, 13 insertions(+), 13 deletions(-) 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 7f035dfc..0b4ac90f 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,10 +30,10 @@ public record FeeResponse( /** 한 번도 요청하지 않은 학생. 남은 학기 수를 받았으면 그 값으로 금액과 송금 링크를 계산한다. */ public static FeeResponse notRequested(FeeAccount account, Integer remainingSemesters) { - Long transferAmount = amountOf(account, remainingSemesters); + Long transferAmount = calculateTransferAmount(account, remainingSemesters); return new FeeResponse(null, NOT_REQUESTED_STATUS, account.bank(), account.accountNo(), - remainingSemesters, transferAmount, linkOf(account, transferAmount), null, null, null); + remainingSemesters, transferAmount, generateTransferLinkUrl(account, transferAmount), null, null, null); } /** 요청이 있는 학생. remainingSemesters는 확인필요(다시 낼 수 있는 상태)일 때만 쓴다. */ @@ -42,9 +42,9 @@ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount ac // 반려면 다시 낼 수 있으므로 미납부와 같이 다룬다 — 이전 요청 값 대신 이번에 받은 남은 학기 수로 계산한다 if (status == TransferStatus.REJECTED) { - Long transferAmount = amountOf(account, remainingSemesters); + Long transferAmount = calculateTransferAmount(account, remainingSemesters); return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), - remainingSemesters, transferAmount, linkOf(account, transferAmount), + remainingSemesters, transferAmount, generateTransferLinkUrl(account, transferAmount), transferStatus.getRequestedAt(), null, transferStatus.getAdminNote()); } @@ -56,11 +56,12 @@ remainingSemesters, transferAmount, linkOf(account, transferAmount), null); } - private static Long amountOf(FeeAccount account, Integer remainingSemesters) { + // 남은 학기 수를 받지 않았으면 금액도 송금 링크도 계산하지 않는다 + private static Long calculateTransferAmount(FeeAccount account, Integer remainingSemesters) { return remainingSemesters == null ? null : account.calculateTransferAmount(remainingSemesters); } - private static String linkOf(FeeAccount account, Long transferAmount) { + private static String generateTransferLinkUrl(FeeAccount account, Long transferAmount) { return transferAmount == null ? null : 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 c6d5f1e9..be465fa5 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 @@ -19,7 +19,7 @@ * 학생회비 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 한 번에 받는다. * {@code welfare}(납부 요청)와 {@code file}(인증 이미지) 두 도메인을 조합한다. *

- * 메서드에 {@code @Transactional}을 붙이지 않는다. 쓰기는 {@code StudentTransferStatusService.request} 하나뿐이고 + * 메서드에 {@code @Transactional}을 붙이지 않는다. 쓰기는 {@code StudentTransferStatusService.submit} 하나뿐이고 * 그 트랜잭션 안에서 끝난다. 인증 이미지는 조회해서 검증만 한다. *

* 다시 내도 이전 인증 이미지를 지우지 않는다. 반려된 이전 요청이 이력으로 남아 그 이미지를 계속 가리킨다. @@ -39,7 +39,7 @@ public FeeResponse submit(Long memberId, FeeSubmitRequest request) { FeeAccount account = feeAccountProvider.getAccount(); long amount = account.calculateTransferAmount(request.remainingSemesters()); - StudentTransferStatus saved = studentTransferStatusService.request( + StudentTransferStatus saved = studentTransferStatusService.submit( memberId, request.remainingSemesters(), request.proofImageKey(), amount); return FeeResponse.of(saved, account, null); } 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 b9ba3a64..58df07db 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 @@ -31,7 +31,7 @@ public class StudentTransferStatus { * 송금을 마친 학생의 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 한 번에 받으므로 * 바로 납부확인중이 된다. 처음 내든 반려 뒤 다시 내든 늘 새 요청(행)이고, 이전 요청은 이력으로 남는다. */ - public static StudentTransferStatus request( + public static StudentTransferStatus create( Long memberId, int remainingSemesters, String proofImageKey, 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 4ce2c64e..d5794c7f 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 @@ -19,7 +19,7 @@ public interface StudentTransferStatusService { * * @param amount 요청 시점에 안내한 납부 금액 */ - StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount); + StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey, long amount); /** 회원별 최신 납부 요청. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 한 번도 요청하지 않은 회원은 빠진다. */ Map getMapByMemberIds(List memberIds); 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 48b43c58..41ba1d78 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 @@ -29,7 +29,6 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService { private final StudentTransferStatusRepository studentTransferStatusRepository; private final OutboxWriter outboxWriter; - // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다). @Override public Optional findLatestByMemberId(Long memberId) { return studentTransferStatusRepository.findLatestByMemberId(memberId); @@ -46,13 +45,13 @@ public Map getMapByMemberIds(List memberIds) @Override @Transactional - public StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount) { + public StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey, long amount) { // 반려된 요청 뒤에만 새 요청을 받는다. 이전 요청은 고치지 않고 이력으로 남긴다 studentTransferStatusRepository.findLatestByMemberId(memberId) .ifPresent(StudentTransferStatus::validateRequestable); // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V26)가 두 번째 저장을 막는다 - StudentTransferStatus transferStatus = StudentTransferStatus.request( + StudentTransferStatus transferStatus = StudentTransferStatus.create( memberId, remainingSemesters, proofImageKey, amount, LocalDateTime.now(DateUtil.KST)); return studentTransferStatusRepository.save(transferStatus); } From c7e81f8726c410ae0f0dc7fca5232553c80d656c 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: Tue, 27 Oct 2026 17:57:44 +0900 Subject: [PATCH 06/18] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20?= =?UTF-8?q?=EA=B3=84=EC=A2=8C=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20VO=EB=A1=9C=20=ED=95=A9=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/fee/AdminFeeAccountProvider.java | 19 +++---- .../admin/welfare/fee/AdminFeeController.java | 1 + .../api/admin/welfare/fee/FeeAccount.java | 26 --------- .../fee/response/FeeAccountResponse.java | 4 +- .../api/app/welfare/fee/AppFeeController.java | 1 + .../api/app/welfare/fee/FeeAccount.java | 23 -------- .../app/welfare/fee/FeeAccountProvider.java | 19 ++++--- .../app/welfare/fee/response/FeeResponse.java | 2 +- .../welfare/fee/usecase/FeeSubmitUseCase.java | 2 +- .../stream/welfare/WelfareConstants.java | 2 +- .../welfare/domain/fee/domain/FeeAccount.java | 56 +++++++++++++++++++ .../welfare/domain/fee/domain/FeeBanks.java | 26 --------- 12 files changed, 82 insertions(+), 99 deletions(-) delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeAccount.java delete mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java index 1d2b30d6..a1c53ff7 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java @@ -3,7 +3,7 @@ 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.FeeBanks; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeAccount; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -21,7 +21,7 @@ public class AdminFeeAccountProvider { private final AdminConfigValueService adminConfigValueService; public FeeAccount getAccount() { - return new FeeAccount( + return FeeAccount.of( adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null), adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null), adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT) @@ -30,15 +30,14 @@ public FeeAccount getAccount() { ); } - // 계좌와 금액은 한 화면에서 같이 저장하므로 세 키를 한 번에 쓴다. - // 은행명은 토스 송금 링크에 그대로 들어가므로 허용 목록에 있는 은행만 저장한다 - public FeeAccount save(String bank, String accountNo, long amount) { - FeeBanks.validate(bank); + // 계좌와 금액은 한 화면에서 같이 저장하므로 세 키를 한 번에 쓴다 + public FeeAccount save(String bank, String accountNo, long amountPerSemester) { + FeeAccount account = FeeAccount.create(bank, accountNo, amountPerSemester); adminConfigValueService.upsertValues(Map.of( - FeeConfigKeys.TRANSFER_BANK, bank, - FeeConfigKeys.TRANSFER_ACCOUNT_NO, accountNo, - FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(amount) + FeeConfigKeys.TRANSFER_BANK, account.bank(), + FeeConfigKeys.TRANSFER_ACCOUNT_NO, account.accountNo(), + FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(account.amountPerSemester()) )); - return new FeeAccount(bank, accountNo, amount); + return account; } } 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 e40c0399..ddd8b445 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 @@ -15,6 +15,7 @@ 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.welfare.domain.fee.domain.FeeAccount; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; import lombok.RequiredArgsConstructor; 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 deleted file mode 100644 index 2f4439e6..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java +++ /dev/null @@ -1,26 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.welfare.fee; - -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 generateTransferLinkUrl() { - return isConfigured() ? TossTransferLinkGenerator.generate(bank, accountNo, null) : null; - } -} 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 be558e2e..53a6d5d1 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 @@ -1,6 +1,6 @@ package kr.ac.kookmin.stream.api.admin.welfare.fee.response; -import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeAccount; /** 회비 납부 설정. 조회와 변경이 같은 모양을 쓴다. */ public record FeeAccountResponse( @@ -11,6 +11,6 @@ public record FeeAccountResponse( ) { public static FeeAccountResponse from(FeeAccount account) { return new FeeAccountResponse( - account.bank(), account.accountNo(), account.amount(), account.generateTransferLinkUrl()); + account.bank(), account.accountNo(), account.amountPerSemester(), account.generateBaseTransferLinkUrl()); } } 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 bf516bca..b75f5825 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 @@ -7,6 +7,7 @@ import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.FeeSubmitUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeAccount; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; 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 deleted file mode 100644 index 5d7a2f3e..00000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java +++ /dev/null @@ -1,23 +0,0 @@ -package kr.ac.kookmin.stream.api.app.welfare.fee; - -import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; -import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator; - -/** - * 관리자가 설정한 회비 납부 설정. 셋 다 {@code admin_config_values}에 들어 있고 같이 읽힌다. - * 계좌는 전역 하나이며, 학생마다 다른 것은 금액뿐이다. - * - * @param amountPerSemester 학기당 회비 - */ -public record FeeAccount(String bank, String accountNo, long amountPerSemester) { - - /** 이 학생이 보낼 금액. 남은 학기 수 × 학기당 회비. */ - public long calculateTransferAmount(int remainingSemesters) { - return FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester); - } - - /** 금액이 붙은 토스 송금 링크. */ - 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/FeeAccountProvider.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java index 85c2f6c8..feb0d2d4 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java @@ -3,6 +3,7 @@ import kr.ac.kookmin.stream.common.BusinessException; 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.FeeAccount; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; import lombok.RequiredArgsConstructor; @@ -21,19 +22,19 @@ public class FeeAccountProvider { private final AdminConfigValueService adminConfigValueService; - /** 계좌가 설정되지 않았으면 송금 자체가 불가능하므로 막는다. */ public FeeAccount getAccount() { - String bank = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null); - String accountNo = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null); - - if (bank == null || accountNo == null) { - throw new BusinessException(FeeErrorCode.FEE_ACCOUNT_NOT_FOUND); - } - + String bank = getRequiredValue(FeeConfigKeys.TRANSFER_BANK); + String accountNo = getRequiredValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO); long amountPerSemester = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT) .map(Long::parseLong) .orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT); - return new FeeAccount(bank, accountNo, amountPerSemester); + return FeeAccount.of(bank, accountNo, amountPerSemester); + } + + // 계좌가 설정되지 않았으면 송금 자체가 불가능하므로 막는다 + private String getRequiredValue(String configKey) { + return adminConfigValueService.getValue(configKey) + .orElseThrow(() -> new BusinessException(FeeErrorCode.FEE_ACCOUNT_NOT_FOUND)); } } 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 0b4ac90f..b77bd39b 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 @@ -1,7 +1,7 @@ package kr.ac.kookmin.stream.api.app.welfare.fee.response; import java.time.LocalDateTime; -import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeAccount; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; 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 be465fa5..6d887c2f 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 @@ -1,7 +1,6 @@ package kr.ac.kookmin.stream.api.app.welfare.fee.usecase; import java.util.Objects; -import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount; 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.FeeResponse; @@ -9,6 +8,7 @@ import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.file.domain.FileCategory; import kr.ac.kookmin.stream.file.service.FileService; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeAccount; 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.service.StudentTransferStatusService; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/WelfareConstants.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/WelfareConstants.java index b47671ef..df414d01 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/WelfareConstants.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/WelfareConstants.java @@ -3,7 +3,7 @@ // welfare 도메인 모듈 전반에서 쓰는 상수 모음. public final class WelfareConstants { - // 한 학기당 회비 기본 금액. 관리자가 PUT /v1/admin/fee/amount로 바꾸기 전까지의 기본값. + // 한 학기당 회비 기본 금액. 관리자가 PUT /v1/admin/fee/account로 바꾸기 전까지의 기본값. public static final long DEFAULT_TRANSFER_AMOUNT = 20_000L; private WelfareConstants() {} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeAccount.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeAccount.java new file mode 100644 index 00000000..dcee76cc --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeAccount.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.welfare.domain.fee.domain; + +import java.util.Set; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; +import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator; + +/** + * 회비 납부 계좌와 학기당 회비. 계좌를 설정하기 전이면 bank·accountNo가 null이다. + *

+ * 은행은 토스 송금 링크의 bank 파라미터와 화면 표시에 그대로 쓰이므로 enum으로 바꾸지 않고 이름 그대로 둔다. + */ +public record FeeAccount(String bank, String accountNo, long amountPerSemester) { + + // 관리자 화면의 은행 선택 목록과 같은 22개 + private static final Set ALLOWED_BANKS = Set.of( + "경남", "광주", "기업", "국민", "대구", "부산", "산림", "새마을", "SC제일", "신한", "신협", + "수협", "케이뱅크", "우리", "우체국", "저축은행", "전북", "제주", "카카오뱅크", "토스뱅크", "하나", "농협" + ); + + /** 운영진이 새로 저장하는 계좌. 허용 목록에 있는 은행인지 검증한다. */ + public static FeeAccount create(String bank, String accountNo, long amountPerSemester) { + validateBank(bank); + return new FeeAccount(bank, accountNo, amountPerSemester); + } + + // 저장된 설정값으로 복원한다. 은행 목록을 검사하지 않던 때 저장된 값이 있을 수 있어 다시 검증하지 않는다 + public static FeeAccount of(String bank, String accountNo, long amountPerSemester) { + return new FeeAccount(bank, accountNo, amountPerSemester); + } + + public boolean isConfigured() { + return bank != null && accountNo != null; + } + + /** 이 학생이 보낼 금액. 남은 학기 수 × 학기당 회비. */ + public long calculateTransferAmount(int remainingSemesters) { + return FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester); + } + + /** 금액이 붙은 토스 송금 링크. */ + public String generateTransferLinkUrl(long transferAmount) { + return TossTransferLinkGenerator.generate(bank, accountNo, transferAmount); + } + + /** 금액이 빠진 송금 기본 링크. 계좌가 설정되기 전이면 null이다. */ + public String generateBaseTransferLinkUrl() { + return isConfigured() ? TossTransferLinkGenerator.generate(bank, accountNo, null) : null; + } + + private static void validateBank(String bank) { + if (bank == null || !ALLOWED_BANKS.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/FeeBanks.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java deleted file mode 100644 index 9f8aad94..00000000 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java +++ /dev/null @@ -1,26 +0,0 @@ -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); - } - } -} From b673e59eb72d2881c1baf41d0ffb411a3a451374 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: Tue, 27 Oct 2026 18:12:22 +0900 Subject: [PATCH 07/18] =?UTF-8?q?refactor:=20=EB=82=A8=EC=9D=80=20?= =?UTF-8?q?=ED=95=99=EA=B8=B0=20=EC=88=98=EC=99=80=20=EC=86=A1=EA=B8=88=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=EB=A5=BC=20=ED=9A=8C=EB=B9=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20VO=EB=A1=9C=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/app/welfare/fee/AppFeeApi.java | 5 +-- .../api/app/welfare/fee/AppFeeController.java | 16 +++------ .../welfare/fee/request/FeeAmountParams.java | 8 ++--- .../welfare/fee/request/FeeSubmitRequest.java | 12 ++++--- .../app/welfare/fee/response/FeeResponse.java | 27 +++++--------- .../welfare/fee/usecase/FeeStatusUseCase.java | 36 +++++++++++++++++++ .../welfare/fee/usecase/FeeSubmitUseCase.java | 14 ++++---- .../welfare/domain/fee/domain/FeeAccount.java | 15 ++++---- .../domain/fee/domain/FeeErrorCode.java | 1 + .../domain/fee/domain/FeeSubmitCommand.java | 3 ++ .../domain/fee/domain/RemainingSemesters.java | 28 +++++++++++++++ .../domain/fee/domain/TransferGuide.java | 17 +++++++++ .../fee/service/FeeAmountCalculator.java | 12 ------- .../service/StudentTransferStatusService.java | 3 +- .../StudentTransferStatusServiceImpl.java | 7 ++-- 15 files changed, 131 insertions(+), 73 deletions(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeStatusUseCase.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeSubmitCommand.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/RemainingSemesters.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferGuide.java delete mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java index 5723cabb..ed41116d 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java @@ -31,7 +31,7 @@ public interface AppFeeApi { + "그 값으로 납부 금액과 송금 링크를 계산해 준다. 납부확인중·납부완료면 요청할 때 남긴 금액만 보여주고 " + "송금 링크는 주지 않는다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_ACCOUNT_NOT_FOUND"}) + @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_REMAINING_SEMESTERS", "FEE_ACCOUNT_NOT_FOUND"}) ApiResponse getFeeByMemberId(AppApiUser apiUser, @ParameterObject FeeAmountParams params); /** 학생회비 납부 요청. 학기 인증과 송금 완료를 한 번에 받아 바로 납부확인중이 된다. */ @@ -42,6 +42,7 @@ public interface AppFeeApi { + "이전 요청은 그대로 남는다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = FeeErrorCode.class, - codes = {"INVALID_PROOF_IMAGE", "FEE_ACCOUNT_NOT_FOUND", "FEE_REQUEST_ALREADY_REQUESTED"}) + codes = {"INVALID_REMAINING_SEMESTERS", "INVALID_PROOF_IMAGE", "FEE_ACCOUNT_NOT_FOUND", + "FEE_REQUEST_ALREADY_REQUESTED"}) ApiResponse submit(AppApiUser apiUser, FeeSubmitRequest request); } 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 b75f5825..f1af805b 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 @@ -5,10 +5,9 @@ import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeAmountParams; import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; +import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.FeeStatusUseCase; import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.FeeSubmitUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeAccount; -import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -22,25 +21,18 @@ @RequiredArgsConstructor public class AppFeeController implements AppFeeApi { - private final StudentTransferStatusService studentTransferStatusService; - private final FeeAccountProvider feeAccountProvider; + private final FeeStatusUseCase feeStatusUseCase; private final FeeSubmitUseCase feeSubmitUseCase; - // 남은 학기 수는 금액과 송금 링크를 계산할 때만 쓴다. 이 조회는 아무것도 저장하지 않는다 @Override @GetMapping("/me") public ApiResponse getFeeByMemberId(AppApiUser apiUser, @Valid @ModelAttribute FeeAmountParams params) { - FeeAccount account = feeAccountProvider.getAccount(); - - FeeResponse response = studentTransferStatusService.findLatestByMemberId(apiUser.userId()) - .map(transferStatus -> FeeResponse.of(transferStatus, account, params.remainingSemesters())) - .orElseGet(() -> FeeResponse.notRequested(account, params.remainingSemesters())); - return ApiResponse.success(response); + return ApiResponse.success(feeStatusUseCase.getMyFee(apiUser.userId(), params.remainingSemesters())); } @Override @PostMapping public ApiResponse submit(AppApiUser apiUser, @Valid @RequestBody FeeSubmitRequest request) { - return ApiResponse.success(feeSubmitUseCase.submit(apiUser.userId(), request)); + return ApiResponse.success(feeSubmitUseCase.submit(apiUser.userId(), request.toCommand())); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeAmountParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeAmountParams.java index 2bd3076f..d6455c21 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeAmountParams.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeAmountParams.java @@ -1,14 +1,10 @@ package kr.ac.kookmin.stream.api.app.welfare.fee.request; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; - /** - * @param remainingSemesters 남은 학기 수. 주면 그 값으로 납부 금액과 송금 링크를 계산하고, 없으면 계산하지 않는다 + * @param remainingSemesters 남은 학기 수. 주면 그 값으로 납부 금액과 송금 링크를 계산하고, 없으면 계산하지 않는다. + * 범위(1~8)는 회비 정책이라 {@code RemainingSemesters}가 검증한다 */ public record FeeAmountParams( - @Min(value = 1, message = "남은 학기 수는 1 이상 8 이하여야 합니다.") - @Max(value = 8, message = "남은 학기 수는 1 이상 8 이하여야 합니다.") Integer remainingSemesters ) { } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java index ddadecf8..fe6ae1ab 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java @@ -1,10 +1,11 @@ package kr.ac.kookmin.stream.api.app.welfare.fee.request; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeSubmitCommand; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.RemainingSemesters; /** * 학생회비 납부 요청. 송금을 마친 뒤 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 함께 보낸다. @@ -12,12 +13,15 @@ public record FeeSubmitRequest( @Schema(description = "남은 학기 수 (1~8)", example = "6") @NotNull(message = "남은 학기 수를 입력해 주세요.") - @Min(value = 1, message = "남은 학기 수는 1 이상 8 이하여야 합니다.") - @Max(value = 8, message = "남은 학기 수는 1 이상 8 이하여야 합니다.") Integer remainingSemesters, @Schema(description = "업로드 URL 발급 때 받은 이미지 키", example = "files/3f2a9c1e-7b4d-4e8a-9c2f-1a2b3c4d5e6f.png") @NotBlank(message = "학점이수현황 캡처를 첨부해 주세요.") + @Size(max = 255, message = "이미지 키는 255자 이하여야 합니다.") String proofImageKey ) { + + public FeeSubmitCommand toCommand() { + return new FeeSubmitCommand(RemainingSemesters.of(remainingSemesters), proofImageKey); + } } 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 b77bd39b..b3ae615a 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 @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeAccount; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferGuide; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; /** @@ -28,23 +29,20 @@ public record FeeResponse( // 한 번도 요청하지 않은 회원의 상태. 도메인 상태에는 없고(행이 없다) 응답에서만 쓰는 이름이다 private static final String NOT_REQUESTED_STATUS = "UNPAID"; - /** 한 번도 요청하지 않은 학생. 남은 학기 수를 받았으면 그 값으로 금액과 송금 링크를 계산한다. */ - public static FeeResponse notRequested(FeeAccount account, Integer remainingSemesters) { - Long transferAmount = calculateTransferAmount(account, remainingSemesters); - + /** 한 번도 요청하지 않은 학생. 남은 학기 수를 받았으면 계산한 금액과 송금 링크를 함께 준다. */ + public static FeeResponse notRequested(FeeAccount account, TransferGuide transferGuide) { return new FeeResponse(null, NOT_REQUESTED_STATUS, account.bank(), account.accountNo(), - remainingSemesters, transferAmount, generateTransferLinkUrl(account, transferAmount), null, null, null); + transferGuide.remainingSemesters(), transferGuide.amount(), transferGuide.linkUrl(), null, null, null); } - /** 요청이 있는 학생. remainingSemesters는 확인필요(다시 낼 수 있는 상태)일 때만 쓴다. */ - public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account, Integer remainingSemesters) { + /** 요청이 있는 학생. transferGuide는 확인필요(다시 낼 수 있는 상태)일 때만 쓴다. */ + public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account, TransferGuide transferGuide) { TransferStatus status = transferStatus.getStatus(); - // 반려면 다시 낼 수 있으므로 미납부와 같이 다룬다 — 이전 요청 값 대신 이번에 받은 남은 학기 수로 계산한다 + // 반려면 다시 낼 수 있으므로 미납부와 같이 다룬다 — 이전 요청 값 대신 이번에 받은 남은 학기 수로 계산한 값을 준다 if (status == TransferStatus.REJECTED) { - Long transferAmount = calculateTransferAmount(account, remainingSemesters); return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), - remainingSemesters, transferAmount, generateTransferLinkUrl(account, transferAmount), + transferGuide.remainingSemesters(), transferGuide.amount(), transferGuide.linkUrl(), transferStatus.getRequestedAt(), null, transferStatus.getAdminNote()); } @@ -55,13 +53,4 @@ remainingSemesters, transferAmount, generateTransferLinkUrl(account, transferAmo status == TransferStatus.PAID ? transferStatus.getReviewedAt() : null, null); } - - // 남은 학기 수를 받지 않았으면 금액도 송금 링크도 계산하지 않는다 - private static Long calculateTransferAmount(FeeAccount account, Integer remainingSemesters) { - return remainingSemesters == null ? null : account.calculateTransferAmount(remainingSemesters); - } - - private static String generateTransferLinkUrl(FeeAccount account, Long transferAmount) { - return transferAmount == null ? null : account.generateTransferLinkUrl(transferAmount); - } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeStatusUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeStatusUseCase.java new file mode 100644 index 00000000..5feffc5d --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeStatusUseCase.java @@ -0,0 +1,36 @@ +package kr.ac.kookmin.stream.api.app.welfare.fee.usecase; + +import java.util.Optional; +import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccountProvider; +import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeAccount; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.RemainingSemesters; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferGuide; +import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 내 학생회비 납부 현황. 회비 납부 설정({@code internal})과 납부 요청({@code welfare})을 조합한다. + * 아무것도 저장하지 않는다. + */ +@Component +@RequiredArgsConstructor +public class FeeStatusUseCase { + + private final StudentTransferStatusService studentTransferStatusService; + private final FeeAccountProvider feeAccountProvider; + + public FeeResponse getMyFee(Long memberId, Integer remainingSemesters) { + FeeAccount account = feeAccountProvider.getAccount(); + // 남은 학기 수는 선택값이라 여기서 한 번만 판단한다 + TransferGuide transferGuide = Optional.ofNullable(remainingSemesters) + .map(RemainingSemesters::of) + .map(account::createTransferGuide) + .orElse(TransferGuide.notCalculated()); + + return studentTransferStatusService.findLatestByMemberId(memberId) + .map(transferStatus -> FeeResponse.of(transferStatus, account, transferGuide)) + .orElseGet(() -> FeeResponse.notRequested(account, transferGuide)); + } +} 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 6d887c2f..288bdfc4 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,6 @@ import java.util.Objects; 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.FeeResponse; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.file.domain.File; @@ -10,7 +9,9 @@ import kr.ac.kookmin.stream.file.service.FileService; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeAccount; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeSubmitCommand; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferGuide; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -32,16 +33,15 @@ public class FeeSubmitUseCase { private final FeeAccountProvider feeAccountProvider; private final FileService fileService; - public FeeResponse submit(Long memberId, FeeSubmitRequest request) { - validateProofImage(request.proofImageKey(), memberId); + public FeeResponse submit(Long memberId, FeeSubmitCommand command) { + validateProofImage(command.proofImageKey(), memberId); // 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다 FeeAccount account = feeAccountProvider.getAccount(); - long amount = account.calculateTransferAmount(request.remainingSemesters()); + long amount = account.calculateTransferAmount(command.remainingSemesters()); - StudentTransferStatus saved = studentTransferStatusService.submit( - memberId, request.remainingSemesters(), request.proofImageKey(), amount); - return FeeResponse.of(saved, account, null); + StudentTransferStatus saved = studentTransferStatusService.submit(memberId, command, amount); + return FeeResponse.of(saved, account, TransferGuide.notCalculated()); } // 실재하는 키인지, 본인이 올린 것인지, 학점이수현황 캡처로 올린 것인지 본다. diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeAccount.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeAccount.java index dcee76cc..35ca206d 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeAccount.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeAccount.java @@ -2,7 +2,6 @@ import java.util.Set; import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator; /** @@ -33,14 +32,16 @@ public boolean isConfigured() { return bank != null && accountNo != null; } - /** 이 학생이 보낼 금액. 남은 학기 수 × 학기당 회비. */ - public long calculateTransferAmount(int remainingSemesters) { - return FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester); + /** 남은 학기 수로 계산한 금액과, 그 금액이 채워진 토스 송금 링크. */ + public TransferGuide createTransferGuide(RemainingSemesters remainingSemesters) { + long amount = calculateTransferAmount(remainingSemesters); + return TransferGuide.of(remainingSemesters.value(), amount, + TossTransferLinkGenerator.generate(bank, accountNo, amount)); } - /** 금액이 붙은 토스 송금 링크. */ - public String generateTransferLinkUrl(long transferAmount) { - return TossTransferLinkGenerator.generate(bank, accountNo, transferAmount); + /** 이 학생이 보낼 금액. 남은 학기 수 × 학기당 회비. */ + public long calculateTransferAmount(RemainingSemesters remainingSemesters) { + return remainingSemesters.calculateAmount(amountPerSemester); } /** 금액이 빠진 송금 기본 링크. 계좌가 설정되기 전이면 null이다. */ 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 262366e3..b735506d 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 @@ -13,6 +13,7 @@ public enum FeeErrorCode implements ErrorCode { // 상태 문자열이 PENDING/PAID/REJECTED가 아닌 경우에 쓴다 INVALID_TRANSFER_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 납부 상태입니다."), + INVALID_REMAINING_SEMESTERS(ErrorStatus.BAD_REQUEST, "유효하지 않은 남은 학기 수입니다."), INVALID_PROOF_IMAGE(ErrorStatus.BAD_REQUEST, "유효하지 않은 인증 이미지입니다."), INVALID_BANK(ErrorStatus.BAD_REQUEST, "지원하지 않는 은행입니다."), INVALID_ADMIN_NOTE(ErrorStatus.BAD_REQUEST, "확인필요로 처리하려면 안내 문구를 입력해 주세요."), diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeSubmitCommand.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeSubmitCommand.java new file mode 100644 index 00000000..0124b960 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeSubmitCommand.java @@ -0,0 +1,3 @@ +package kr.ac.kookmin.stream.welfare.domain.fee.domain; + +public record FeeSubmitCommand(RemainingSemesters remainingSemesters, String proofImageKey) {} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/RemainingSemesters.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/RemainingSemesters.java new file mode 100644 index 00000000..b3e93f6d --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/RemainingSemesters.java @@ -0,0 +1,28 @@ +package kr.ac.kookmin.stream.welfare.domain.fee.domain; + +import kr.ac.kookmin.stream.common.BusinessException; + +/** + * 학생이 입력한 남은 학기 수. 회비는 남은 학기 수만큼만 걷고, 학점이수현황 캡처로 증빙한다. + */ +public record RemainingSemesters(int value) { + + private static final int MIN = 1; + private static final int MAX = 8; + + public static RemainingSemesters of(int value) { + validateRange(value); + return new RemainingSemesters(value); + } + + /** 이 학생이 낼 금액. 남은 학기 수 × 학기당 회비. */ + public long calculateAmount(long amountPerSemester) { + return (long) value * amountPerSemester; + } + + private static void validateRange(int value) { + if (value < MIN || value > MAX) { + throw new BusinessException(FeeErrorCode.INVALID_REMAINING_SEMESTERS); + } + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferGuide.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferGuide.java new file mode 100644 index 00000000..14d7b847 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferGuide.java @@ -0,0 +1,17 @@ +package kr.ac.kookmin.stream.welfare.domain.fee.domain; + +/** + * 학생에게 지금 안내할 송금 정보 — 입력한 남은 학기 수, 그 값으로 계산한 금액, 금액이 채워진 토스 송금 링크. + * 조회할 때마다 계산하고 저장하지 않는다. 요청 때 저장해 두는 금액은 StudentTransferStatus의 amount다. + */ +public record TransferGuide(Integer remainingSemesters, Long amount, String linkUrl) { + + public static TransferGuide of(int remainingSemesters, long amount, String linkUrl) { + return new TransferGuide(remainingSemesters, amount, linkUrl); + } + + // 남은 학기 수를 받지 않아 계산하지 않았다. 응답에서 남은 학기 수·금액·링크가 비어 내려간다 + public static TransferGuide notCalculated() { + return new TransferGuide(null, null, null); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java deleted file mode 100644 index 33f15477..00000000 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java +++ /dev/null @@ -1,12 +0,0 @@ -package kr.ac.kookmin.stream.welfare.domain.fee.service; - -// 남은 학기 수만큼만 회비를 걷는다 — 남은 학기 수 × 학기당 금액. -// 남은 학기 수는 학생이 직접 입력하고 학점이수현황 캡처로 증빙한다. -public final class FeeAmountCalculator { - - private FeeAmountCalculator() {} - - public static long calculate(int remainingSemesters, long amountPerSemester) { - return (long) remainingSemesters * amountPerSemester; - } -} 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 d5794c7f..95b45b92 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 @@ -5,6 +5,7 @@ import java.util.Optional; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeSubmitCommand; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; @@ -19,7 +20,7 @@ public interface StudentTransferStatusService { * * @param amount 요청 시점에 안내한 납부 금액 */ - StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey, long amount); + StudentTransferStatus submit(Long memberId, FeeSubmitCommand command, long amount); /** 회원별 최신 납부 요청. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 한 번도 요청하지 않은 회원은 빠진다. */ Map getMapByMemberIds(List memberIds); 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 41ba1d78..298cdad4 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 @@ -14,6 +14,7 @@ import kr.ac.kookmin.stream.common.event.MemberFeePaid; import kr.ac.kookmin.stream.common.event.MemberFeePaymentCanceled; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeSubmitCommand; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.repository.StudentTransferStatusRepository; @@ -45,14 +46,14 @@ public Map getMapByMemberIds(List memberIds) @Override @Transactional - public StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey, long amount) { + public StudentTransferStatus submit(Long memberId, FeeSubmitCommand command, long amount) { // 반려된 요청 뒤에만 새 요청을 받는다. 이전 요청은 고치지 않고 이력으로 남긴다 studentTransferStatusRepository.findLatestByMemberId(memberId) .ifPresent(StudentTransferStatus::validateRequestable); // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V26)가 두 번째 저장을 막는다 - StudentTransferStatus transferStatus = StudentTransferStatus.create( - memberId, remainingSemesters, proofImageKey, amount, LocalDateTime.now(DateUtil.KST)); + StudentTransferStatus transferStatus = StudentTransferStatus.create(memberId, + command.remainingSemesters().value(), command.proofImageKey(), amount, LocalDateTime.now(DateUtil.KST)); return studentTransferStatusRepository.save(transferStatus); } From 681362ffb39400fd3e316596cd226d119b688610 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: Tue, 27 Oct 2026 18:13:53 +0900 Subject: [PATCH 08/18] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=8C=90=EB=8B=A8=EA=B3=BC=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=AA=A8?= =?UTF-8?q?=EC=9D=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fee/request/FeeStatusUpdateRequest.java | 4 +-- .../fee/response/AdminFeeSearchResponse.java | 4 +-- .../fee/response/FeeStatusUpdateResponse.java | 4 +-- .../app/welfare/fee/response/FeeResponse.java | 7 ++-- .../domain/fee/domain/FeeErrorCode.java | 2 +- .../fee/domain/StudentTransferStatus.java | 32 +++++++++++++++---- 6 files changed, 32 insertions(+), 21 deletions(-) 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 0bbc11fc..05303b37 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 @@ -2,11 +2,10 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; /** - * 납부 상태 변경. 안내 문구는 확인필요(REJECTED)일 때만 필수라, 그 판단은 도메인이 한다. + * 납부 상태 변경. 안내 문구는 확인필요(REJECTED)일 때만 필수라, 필수 여부와 길이는 도메인이 검증한다. */ public record FeeStatusUpdateRequest( @Schema(description = "변경할 납부 상태", example = "REJECTED") @@ -15,7 +14,6 @@ public record FeeStatusUpdateRequest( @Schema(description = "관리자 안내 문구 (최대 255자). 확인필요일 때 필수", example = "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.") - @Size(max = 255, message = "안내 문구는 255자 이하로 입력해 주세요.") 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 cc8f6874..82ea24dc 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 @@ -5,7 +5,6 @@ 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; /** * 납부자 목록의 한 줄. 납부를 요청한 회원만 들어온다 — 한 번도 요청하지 않은 회원은 행이 없다. @@ -47,8 +46,7 @@ public static AdminFeeSearchResponse of( transferStatus.getAmount(), StorageUrlBuilder.build(transferStatus.getProofImageKey()), transferStatus.getRequestedAt(), - // 납부 확인 시각은 납부완료일 때의 처리 시각이다 - transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null, + transferStatus.getConfirmedAt(), transferStatus.getAdminNote() ); } 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 10e02653..47599753 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,7 +2,6 @@ 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, @@ -19,8 +18,7 @@ public static FeeStatusUpdateResponse from(StudentTransferStatus transferStatus) transferStatus.getStatus().name(), transferStatus.getAdminNote(), transferStatus.getReviewedAt(), - // 납부 확인 시각은 납부완료일 때의 처리 시각이다 - transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null + transferStatus.getConfirmedAt() ); } } 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 b3ae615a..09957930 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 @@ -40,7 +40,7 @@ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount ac TransferStatus status = transferStatus.getStatus(); // 반려면 다시 낼 수 있으므로 미납부와 같이 다룬다 — 이전 요청 값 대신 이번에 받은 남은 학기 수로 계산한 값을 준다 - if (status == TransferStatus.REJECTED) { + if (transferStatus.isRequestable()) { return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), transferGuide.remainingSemesters(), transferGuide.amount(), transferGuide.linkUrl(), transferStatus.getRequestedAt(), null, transferStatus.getAdminNote()); @@ -48,9 +48,6 @@ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount ac return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), transferStatus.getRemainingSemesters(), transferStatus.getAmount(), null, - transferStatus.getRequestedAt(), - // 납부 확인 시각은 PAID일 때의 처리 시각이다 - status == TransferStatus.PAID ? transferStatus.getReviewedAt() : null, - null); + transferStatus.getRequestedAt(), transferStatus.getConfirmedAt(), null); } } 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 b735506d..86c5c43f 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 @@ -16,7 +16,7 @@ public enum FeeErrorCode implements ErrorCode { INVALID_REMAINING_SEMESTERS(ErrorStatus.BAD_REQUEST, "유효하지 않은 남은 학기 수입니다."), INVALID_PROOF_IMAGE(ErrorStatus.BAD_REQUEST, "유효하지 않은 인증 이미지입니다."), INVALID_BANK(ErrorStatus.BAD_REQUEST, "지원하지 않는 은행입니다."), - INVALID_ADMIN_NOTE(ErrorStatus.BAD_REQUEST, "확인필요로 처리하려면 안내 문구를 입력해 주세요."), + INVALID_ADMIN_NOTE(ErrorStatus.BAD_REQUEST, "확인필요 안내 문구는 1자 이상 255자 이하로 입력해 주세요."), FEE_ACCOUNT_NOT_FOUND(ErrorStatus.NOT_FOUND, "학생회비 납부 계좌가 등록되지 않았습니다."), FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "제출된 납부 정보가 없습니다."), FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."), 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 58df07db..f358ed8c 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 @@ -12,6 +12,9 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class StudentTransferStatus { + // admin_note 컬럼 길이 + private static final int MAX_ADMIN_NOTE_LENGTH = 255; + private Long id; private Long memberId; private TransferStatus status; @@ -60,15 +63,24 @@ public static StudentTransferStatus of( } /** - * 이 요청 다음에 새 납부 요청을 받을 수 있는지 본다. 반려된 요청 뒤에만 다시 낼 수 있다 — + * 이 요청 다음에 학생이 새 납부 요청을 낼 수 있는지. 반려된 요청 뒤에만 다시 낼 수 있다 — * 납부확인중이면 이미 운영진에게 요청이 가 있고, 납부완료면 더 낼 것이 없다. */ + public boolean isRequestable() { + return status == TransferStatus.REJECTED; + } + public void validateRequestable() { - if (status != TransferStatus.REJECTED) { + if (!isRequestable()) { throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED); } } + /** 납부 확인 시각. 납부완료일 때의 처리 시각이다. */ + public LocalDateTime getConfirmedAt() { + return status == TransferStatus.PAID ? reviewedAt : null; + } + /** * 관리자가 납부 상태를 바꾼다. 이미 처리한 요청도 다시 바꿀 수 있다 — 잘못 처리한 경우를 되돌리기 위해서다. * 그래서 납부완료도 종결 상태가 아니다. @@ -77,12 +89,20 @@ public void validateRequestable() { * 납부확인중으로 되돌리면 아직 처리 전이라는 뜻이므로 처리 시각도 지운다. */ public void review(TransferStatus reviewedStatus, String adminNote, LocalDateTime now) { - if (reviewedStatus == TransferStatus.REJECTED && (adminNote == null || adminNote.isBlank())) { - throw new BusinessException(FeeErrorCode.INVALID_ADMIN_NOTE); - } + validateAdminNote(reviewedStatus, adminNote); this.status = reviewedStatus; - this.adminNote = reviewedStatus == TransferStatus.REJECTED ? adminNote : null; + this.adminNote = reviewedStatus == TransferStatus.REJECTED ? adminNote.strip() : null; this.reviewedAt = reviewedStatus == TransferStatus.PENDING ? null : now; } + + // 확인필요는 학생에게 보여줄 안내 문구가 있어야 의미가 있다. 그 외 상태에서는 문구를 받아도 버린다 + private static void validateAdminNote(TransferStatus status, String adminNote) { + if (status != TransferStatus.REJECTED) { + return; + } + if (adminNote == null || adminNote.isBlank() || adminNote.strip().length() > MAX_ADMIN_NOTE_LENGTH) { + throw new BusinessException(FeeErrorCode.INVALID_ADMIN_NOTE); + } + } } From d556c78ca6f12132ad3d78b067e91f68f4a3f0d7 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: Tue, 27 Oct 2026 18:14:35 +0900 Subject: [PATCH 09/18] =?UTF-8?q?refactor:=20=EC=95=88=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=ED=9A=8C=EC=9B=90=EB=B3=84=20=EC=B5=9C=EC=8B=A0=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B3=A0=20=EA=B2=80=EC=A6=9D=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StudentTransferStatusRepository.java | 1 - .../service/StudentTransferStatusService.java | 6 +----- .../impl/StudentTransferStatusServiceImpl.java | 16 ++-------------- .../StudentTransferStatusJpaRepository.java | 7 ------- .../StudentTransferStatusRepositoryImpl.java | 7 ------- 5 files changed, 3 insertions(+), 34 deletions(-) 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 87adf5c5..229aac28 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 @@ -11,7 +11,6 @@ public interface StudentTransferStatusRepository { Optional findLatestByMemberId(Long memberId); Optional findById(Long id); - List findLatestByMemberIds(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 95b45b92..f5c3cadb 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,7 +1,6 @@ package kr.ac.kookmin.stream.welfare.domain.fee.service; import java.util.List; -import java.util.Map; import java.util.Optional; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; @@ -22,15 +21,12 @@ public interface StudentTransferStatusService { */ StudentTransferStatus submit(Long memberId, FeeSubmitCommand command, long amount); - /** 회원별 최신 납부 요청. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 한 번도 요청하지 않은 회원은 빠진다. */ - Map getMapByMemberIds(List memberIds); - // status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음 PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); /** * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남고, - * 확인필요인데 비어 있으면 {@code INVALID_ADMIN_NOTE}다. + * 확인필요인데 비어 있거나 255자를 넘으면 {@code INVALID_ADMIN_NOTE}다. * 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다. * 납부 여부가 바뀌면(납부완료가 되거나 납부완료에서 벗어나면) 같은 트랜잭션에서 아웃박스에 * {@code MemberFeePaid} · {@code MemberFeePaymentCanceled}를 남긴다. 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 298cdad4..505a70ca 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,10 +2,7 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import java.util.Optional; -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.OutboxWriter; @@ -35,15 +32,6 @@ public Optional findLatestByMemberId(Long memberId) { return studentTransferStatusRepository.findLatestByMemberId(memberId); } - @Override - public Map getMapByMemberIds(List memberIds) { - if (memberIds.isEmpty()) { - return Map.of(); - } - return studentTransferStatusRepository.findLatestByMemberIds(memberIds).stream() - .collect(Collectors.toMap(StudentTransferStatus::getMemberId, Function.identity())); - } - @Override @Transactional public StudentTransferStatus submit(Long memberId, FeeSubmitCommand command, long amount) { @@ -70,7 +58,7 @@ public PageResult search(TransferStatus status, List new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND)); - requireLatest(transferStatus); + validateLatest(transferStatus); boolean wasPaid = transferStatus.getStatus() == TransferStatus.PAID; LocalDateTime now = LocalDateTime.now(DateUtil.KST); @@ -94,7 +82,7 @@ private void recordPaidChange(boolean wasPaid, StudentTransferStatus reviewed, L // 지난 요청은 이력이라 바꾸지 않는다. 회원의 지금 상태는 최신 요청 하나뿐이고, 지난 요청을 되살리면 // 회원당 진행 중 요청이 둘이 될 수 있다 - private void requireLatest(StudentTransferStatus transferStatus) { + private void validateLatest(StudentTransferStatus transferStatus) { boolean latest = studentTransferStatusRepository.findLatestByMemberId(transferStatus.getMemberId()) .map(found -> found.getId().equals(transferStatus.getId())) .orElse(false); 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 dc19c3cd..365e4192 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 @@ -17,13 +17,6 @@ public interface StudentTransferStatusJpaRepository extends JpaRepository findFirstByMemberIdOrderByIdDesc(Long memberId); - @Query(""" - SELECT r FROM StudentTransferStatusJpaEntity r - WHERE r.memberId IN :memberIds - AND r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId) - """) - List findLatestByMemberIdIn(@Param("memberIds") List memberIds); - // 회원별 최신 요청만 대상으로 한다. 상태 필터도 최신 요청의 상태로 건다 @Query(value = """ SELECT r FROM StudentTransferStatusJpaEntity r 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 52051f76..9757afef 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 @@ -30,13 +30,6 @@ public Optional findLatestByMemberId(Long memberId) { .map(StudentTransferStatusJpaEntity::toDomain); } - @Override - public List findLatestByMemberIds(List memberIds) { - return studentTransferStatusJpaRepository.findLatestByMemberIdIn(memberIds).stream() - .map(StudentTransferStatusJpaEntity::toDomain) - .toList(); - } - @Override public Optional findById(Long id) { return studentTransferStatusJpaRepository.findById(id).map(StudentTransferStatusJpaEntity::toDomain); From a1d2ee5d71af98735d833c754e7af597f61a6d88 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: Tue, 27 Oct 2026 18:15:08 +0900 Subject: [PATCH 10/18] =?UTF-8?q?docs:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=9A=8C=EB=B9=84=20API=20=EB=AC=B8=EC=84=9C=EC=99=80=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EB=8F=99=EC=9E=91=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/welfare/fee/AdminFeeApi.java | 16 ++++++++++------ .../welfare/domain/fee/domain/FeeErrorCode.java | 2 +- .../welfare/domain/fee/service/PayerService.java | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) 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 f3c51bc9..6c7ab96b 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 @@ -13,15 +13,16 @@ 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}가 맡는다. *

- * 계좌와 학기당 회비는 한 화면에서 같이 저장하므로 하나의 엔드포인트(`/account`)로 묶여 있다. + * 계좌와 학기당 회비는 한 화면에서 같이 저장하므로 하나의 엔드포인트({@code /account})로 묶여 있다. */ -@Tag(name = "학생회비 (운영진)", description = "학생회비 납부 관리") +@Tag(name = "학생회비 관리", description = "운영진 학생회비 납부 확인·설정") public interface AdminFeeApi { /** 납부 요청 목록. 납부를 요청한 회원만 나온다. */ @@ -40,12 +41,15 @@ ApiResponse> getRequests( /** 납부 상태 처리. 이미 처리한 요청도 다시 바꿀 수 있다. */ @Operation(summary = "학생회비 납부 상태 수정", description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. " - + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. " + + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구(255자 이하)가 필요하고, " + + "다른 상태로 바꿀 때는 안내 문구를 보내도 저장하지 않는다. " + "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다. " - + "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다.") + + "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다. " + + "드물게 학생이 새 요청을 내는 순간과 겹치면 늦게 저장되는 쪽이 FEE_REQUEST_ALREADY_REQUESTED로 실패할 수 있다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode(type = FeeErrorCode.class, - codes = {"INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_NOT_LATEST"}) + @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND", + "FEE_REQUEST_NOT_LATEST", "FEE_REQUEST_ALREADY_REQUESTED"}) + @ApiErrorCode(type = MemberErrorCode.class, codes = {"MEMBER_NOT_FOUND"}) ApiResponse updateStatus( AdminApiUser apiUser, Long studentTransferStatusId, 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 86c5c43f..ab267966 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 @@ -18,7 +18,7 @@ public enum FeeErrorCode implements ErrorCode { INVALID_BANK(ErrorStatus.BAD_REQUEST, "지원하지 않는 은행입니다."), INVALID_ADMIN_NOTE(ErrorStatus.BAD_REQUEST, "확인필요 안내 문구는 1자 이상 255자 이하로 입력해 주세요."), FEE_ACCOUNT_NOT_FOUND(ErrorStatus.NOT_FOUND, "학생회비 납부 계좌가 등록되지 않았습니다."), - FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "제출된 납부 정보가 없습니다."), + FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "납부 요청을 찾을 수 없습니다."), FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."), FEE_REQUEST_NOT_LATEST(ErrorStatus.CONFLICT, "회원의 최신 납부 요청만 처리할 수 있습니다."), MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다."); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java index b9e168d3..331ea7f8 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java @@ -4,7 +4,7 @@ public interface PayerService { - // 확인요청 처리 결과를 납부자 명부(Payer)에 반영한다. PAID면 등록, 그 외(UNPAID)면 명부에서 제거한다. + // 확인요청 처리 결과를 납부자 명부(Payer)에 반영한다. PAID면 등록, 그 외면 명부에서 제거한다. void sync(Long memberId, String name, String studentId, TransferStatus status); // 회원이 납부자 명부에 등록돼 있는지 확인하고, 아니면 예외를 던진다(회비 납부 여부가 필요한 다른 도메인의 조건 확인용). From 3712c94aefeae62d80747c9ea9f2561fbe2ded20 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: Tue, 27 Oct 2026 18:34:52 +0900 Subject: [PATCH 11/18] =?UTF-8?q?refactor:=20=EB=82=A9=EB=B6=80=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20=ED=9B=84=EC=86=8D=20?= =?UTF-8?q?member=20=EC=9E=91=EC=97=85=EC=9C=BC=EB=A1=9C=20=EB=AF=B8?= =?UTF-8?q?=EB=A3=AC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fee/usecase/AdminFeeReviewUseCase.java | 1 - .../stream/common/event/MemberFeePaid.java | 10 -------- .../event/MemberFeePaymentCanceled.java | 10 -------- .../service/StudentTransferStatusService.java | 2 -- .../StudentTransferStatusServiceImpl.java | 24 ++----------------- 5 files changed, 2 insertions(+), 45 deletions(-) delete mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java delete mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java 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 9dfd9051..c96a0e1e 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 @@ -12,7 +12,6 @@ /** * 납부 상태 처리. 상태 변경과 납부자 명부 반영이 어긋나면 안 되므로 한 트랜잭션으로 묶는다. - * 서비스가 남기는 아웃박스 이벤트(납부 여부 변경)도 이 트랜잭션에 함께 커밋·롤백된다. */ @Component @RequiredArgsConstructor diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java deleted file mode 100644 index 22e080c9..00000000 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java +++ /dev/null @@ -1,10 +0,0 @@ -package kr.ac.kookmin.stream.common.event; - -import java.time.LocalDateTime; - -/** - * 회원의 학생회비 납부가 확인되었다 — 운영진이 회원의 최신 납부 요청을 납부완료로 처리했다. - * occurredAt은 같은 회원의 납부·납부 취소가 재시도로 순서가 뒤바뀌어 도착할 때 늦은 쪽을 가려내는 데 쓴다. - */ -public record MemberFeePaid(Long memberId, Long studentTransferStatusId, LocalDateTime occurredAt) - implements DomainEvent {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java deleted file mode 100644 index e502402b..00000000 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java +++ /dev/null @@ -1,10 +0,0 @@ -package kr.ac.kookmin.stream.common.event; - -import java.time.LocalDateTime; - -/** - * 운영진이 회원의 납부완료를 정정했다 — 납부완료였던 최신 요청을 확인필요·납부확인중으로 바꿨다. - * occurredAt의 쓰임은 {@link MemberFeePaid}와 같다. - */ -public record MemberFeePaymentCanceled(Long memberId, Long studentTransferStatusId, LocalDateTime occurredAt) - implements DomainEvent {} 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 f5c3cadb..b8a499f0 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 @@ -28,8 +28,6 @@ public interface StudentTransferStatusService { * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남고, * 확인필요인데 비어 있거나 255자를 넘으면 {@code INVALID_ADMIN_NOTE}다. * 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다. - * 납부 여부가 바뀌면(납부완료가 되거나 납부완료에서 벗어나면) 같은 트랜잭션에서 아웃박스에 - * {@code MemberFeePaid} · {@code MemberFeePaymentCanceled}를 남긴다. */ 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 505a70ca..ce73be6e 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 @@ -5,11 +5,8 @@ import java.util.Optional; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.DateUtil; -import kr.ac.kookmin.stream.common.OutboxWriter; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; -import kr.ac.kookmin.stream.common.event.MemberFeePaid; -import kr.ac.kookmin.stream.common.event.MemberFeePaymentCanceled; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeSubmitCommand; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; @@ -25,7 +22,6 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService { private final StudentTransferStatusRepository studentTransferStatusRepository; - private final OutboxWriter outboxWriter; @Override public Optional findLatestByMemberId(Long memberId) { @@ -60,24 +56,8 @@ public StudentTransferStatus review(Long transferStatusId, TransferStatus status .orElseThrow(() -> new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND)); validateLatest(transferStatus); - boolean wasPaid = transferStatus.getStatus() == TransferStatus.PAID; - LocalDateTime now = LocalDateTime.now(DateUtil.KST); - transferStatus.review(status, adminNote, now); - StudentTransferStatus reviewed = studentTransferStatusRepository.save(transferStatus); - - recordPaidChange(wasPaid, reviewed, now); - return reviewed; - } - - // 회원의 납부 여부(최신 요청이 PAID인가)가 바뀔 때만 남긴다. 최신 요청만 처리하므로 이 요청의 변화가 곧 회원의 변화다. - // 상태 변경과 같은 트랜잭션에서 기록해서, 처리가 롤백되면 이벤트도 함께 사라진다(OutboxWriter는 트랜잭션 밖에서 부르면 실패한다). - private void recordPaidChange(boolean wasPaid, StudentTransferStatus reviewed, LocalDateTime now) { - boolean paid = reviewed.getStatus() == TransferStatus.PAID; - if (!wasPaid && paid) { - outboxWriter.record(new MemberFeePaid(reviewed.getMemberId(), reviewed.getId(), now)); - } else if (wasPaid && !paid) { - outboxWriter.record(new MemberFeePaymentCanceled(reviewed.getMemberId(), reviewed.getId(), now)); - } + transferStatus.review(status, adminNote, LocalDateTime.now(DateUtil.KST)); + return studentTransferStatusRepository.save(transferStatus); } // 지난 요청은 이력이라 바꾸지 않는다. 회원의 지금 상태는 최신 요청 하나뿐이고, 지난 요청을 되살리면 From 96d37eb46214e7ce3371f59faf966fab4c3eb2cb 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: Tue, 27 Oct 2026 18:36:00 +0900 Subject: [PATCH 12/18] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EB=B0=98?= =?UTF-8?q?=EB=A0=A4=20=EC=82=AC=EC=9C=A0(rejectReason)=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9D=84=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/welfare/fee/AdminFeeApi.java | 8 +++--- .../admin/welfare/fee/AdminFeeController.java | 2 +- .../fee/request/FeeStatusUpdateRequest.java | 6 ++--- .../fee/response/AdminFeeSearchResponse.java | 4 +-- .../fee/response/FeeStatusUpdateResponse.java | 4 +-- .../fee/usecase/AdminFeeReviewUseCase.java | 4 +-- .../app/welfare/fee/response/FeeResponse.java | 4 +-- .../domain/fee/domain/FeeErrorCode.java | 2 +- .../fee/domain/StudentTransferStatus.java | 26 +++++++++---------- .../service/StudentTransferStatusService.java | 6 ++--- .../StudentTransferStatusServiceImpl.java | 4 +-- .../StudentTransferStatusJpaEntity.java | 6 ++--- 12 files changed, 38 insertions(+), 38 deletions(-) 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 6c7ab96b..c60acc88 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java @@ -41,13 +41,13 @@ ApiResponse> getRequests( /** 납부 상태 처리. 이미 처리한 요청도 다시 바꿀 수 있다. */ @Operation(summary = "학생회비 납부 상태 수정", description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. " - + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구(255자 이하)가 필요하고, " - + "다른 상태로 바꿀 때는 안내 문구를 보내도 저장하지 않는다. " - + "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다. " + + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 반려 사유(255자 이하)가 필요하고, " + + "다른 상태로 바꿀 때는 반려 사유를 보내도 저장하지 않는다. " + + "확인필요로 돌려받은 학생은 반려 사유를 보고 학기 인증부터 다시 낼 수 있다. " + "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다. " + "드물게 학생이 새 요청을 내는 순간과 겹치면 늦게 저장되는 쪽이 FEE_REQUEST_ALREADY_REQUESTED로 실패할 수 있다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND", + @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_REJECT_REASON", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_NOT_LATEST", "FEE_REQUEST_ALREADY_REQUESTED"}) @ApiErrorCode(type = MemberErrorCode.class, codes = {"MEMBER_NOT_FOUND"}) ApiResponse updateStatus( 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 ddd8b445..9034dee1 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 @@ -62,7 +62,7 @@ public ApiResponse updateStatus( @Valid @RequestBody FeeStatusUpdateRequest request ) { StudentTransferStatus reviewed = adminFeeReviewUseCase.review( - studentTransferStatusId, request.status(), request.adminNote()); + studentTransferStatusId, request.status(), request.rejectReason()); return ApiResponse.success(FeeStatusUpdateResponse.from(reviewed)); } 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 05303b37..ed68b99e 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 @@ -5,15 +5,15 @@ import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; /** - * 납부 상태 변경. 안내 문구는 확인필요(REJECTED)일 때만 필수라, 필수 여부와 길이는 도메인이 검증한다. + * 납부 상태 변경. 반려 사유는 확인필요(REJECTED)일 때만 필수라, 필수 여부와 길이는 도메인이 검증한다. */ public record FeeStatusUpdateRequest( @Schema(description = "변경할 납부 상태", example = "REJECTED") @NotNull(message = "변경할 납부 상태를 입력해 주세요.") TransferStatus status, - @Schema(description = "관리자 안내 문구 (최대 255자). 확인필요일 때 필수", + @Schema(description = "학생에게 보여줄 반려 사유 (최대 255자). 확인필요일 때 필수", example = "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.") - String adminNote + String rejectReason ) { } 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 82ea24dc..b1bb7404 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 @@ -22,7 +22,7 @@ public record AdminFeeSearchResponse( String proofImageUrl, LocalDateTime requestedAt, LocalDateTime confirmedAt, - String adminNote + String rejectReason ) { /** * @param member 탈퇴(소프트 삭제)한 회원의 행이면 null — 이름·학번·학과를 알 수 없어 표시용 placeholder를 쓴다 @@ -47,7 +47,7 @@ public static AdminFeeSearchResponse of( StorageUrlBuilder.build(transferStatus.getProofImageKey()), transferStatus.getRequestedAt(), transferStatus.getConfirmedAt(), - transferStatus.getAdminNote() + transferStatus.getRejectReason() ); } } 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 47599753..a536586a 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 @@ -7,7 +7,7 @@ public record FeeStatusUpdateResponse( Long studentTransferStatusId, Long memberId, String status, - String adminNote, + String rejectReason, LocalDateTime reviewedAt, LocalDateTime confirmedAt ) { @@ -16,7 +16,7 @@ public static FeeStatusUpdateResponse from(StudentTransferStatus transferStatus) transferStatus.getId(), transferStatus.getMemberId(), transferStatus.getStatus().name(), - transferStatus.getAdminNote(), + transferStatus.getRejectReason(), transferStatus.getReviewedAt(), transferStatus.getConfirmedAt() ); 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 c96a0e1e..5d7c6333 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 @@ -22,8 +22,8 @@ public class AdminFeeReviewUseCase { private final MemberService memberService; @Transactional - public StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote) { - StudentTransferStatus reviewed = studentTransferStatusService.review(transferStatusId, status, adminNote); + public StudentTransferStatus review(Long transferStatusId, TransferStatus status, String rejectReason) { + StudentTransferStatus reviewed = studentTransferStatusService.review(transferStatusId, status, rejectReason); Member member = memberService.getById(reviewed.getMemberId()); payerService.sync(member.getId(), member.getName(), member.getStudentId(), reviewed.getStatus()); 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 09957930..47d14484 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 @@ -23,7 +23,7 @@ public record FeeResponse( String transferLinkUrl, LocalDateTime requestedAt, LocalDateTime confirmedAt, - String adminNote + String rejectReason ) { // 한 번도 요청하지 않은 회원의 상태. 도메인 상태에는 없고(행이 없다) 응답에서만 쓰는 이름이다 @@ -43,7 +43,7 @@ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount ac if (transferStatus.isRequestable()) { return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), transferGuide.remainingSemesters(), transferGuide.amount(), transferGuide.linkUrl(), - transferStatus.getRequestedAt(), null, transferStatus.getAdminNote()); + transferStatus.getRequestedAt(), null, transferStatus.getRejectReason()); } return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), 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 ab267966..5acc3992 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 @@ -16,7 +16,7 @@ public enum FeeErrorCode implements ErrorCode { INVALID_REMAINING_SEMESTERS(ErrorStatus.BAD_REQUEST, "유효하지 않은 남은 학기 수입니다."), INVALID_PROOF_IMAGE(ErrorStatus.BAD_REQUEST, "유효하지 않은 인증 이미지입니다."), INVALID_BANK(ErrorStatus.BAD_REQUEST, "지원하지 않는 은행입니다."), - INVALID_ADMIN_NOTE(ErrorStatus.BAD_REQUEST, "확인필요 안내 문구는 1자 이상 255자 이하로 입력해 주세요."), + INVALID_REJECT_REASON(ErrorStatus.BAD_REQUEST, "반려 사유는 1자 이상 255자 이하로 입력해 주세요."), FEE_ACCOUNT_NOT_FOUND(ErrorStatus.NOT_FOUND, "학생회비 납부 계좌가 등록되지 않았습니다."), FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "납부 요청을 찾을 수 없습니다."), FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."), 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 f358ed8c..8ac0a50d 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 @@ -13,7 +13,7 @@ public class StudentTransferStatus { // admin_note 컬럼 길이 - private static final int MAX_ADMIN_NOTE_LENGTH = 255; + private static final int MAX_REJECT_REASON_LENGTH = 255; private Long id; private Long memberId; @@ -23,8 +23,8 @@ public class StudentTransferStatus { private String proofImageKey; // 요청 시점에 안내한 납부 금액(남은 학기 수 × 그때의 학기당 회비). 운영진이 학기당 회비를 바꿔도 그대로다 private Long amount; - // 관리자가 확인필요로 처리하며 남긴 안내 문구. REJECTED일 때만 값이 있다 - private String adminNote; + // 관리자가 확인필요로 처리하며 학생에게 남긴 반려 사유. REJECTED일 때만 값이 있다 + private String rejectReason; private LocalDateTime requestedAt; private LocalDateTime reviewedAt; private LocalDateTime createdAt; @@ -52,14 +52,14 @@ public static StudentTransferStatus of( Integer remainingSemesters, String proofImageKey, Long amount, - String adminNote, + String rejectReason, LocalDateTime requestedAt, LocalDateTime reviewedAt, LocalDateTime createdAt, LocalDateTime updatedAt ) { return new StudentTransferStatus(id, memberId, status, remainingSemesters, proofImageKey, - amount, adminNote, requestedAt, reviewedAt, createdAt, updatedAt); + amount, rejectReason, requestedAt, reviewedAt, createdAt, updatedAt); } /** @@ -85,24 +85,24 @@ public LocalDateTime getConfirmedAt() { * 관리자가 납부 상태를 바꾼다. 이미 처리한 요청도 다시 바꿀 수 있다 — 잘못 처리한 경우를 되돌리기 위해서다. * 그래서 납부완료도 종결 상태가 아니다. *

- * 안내 문구는 확인필요일 때만 남기고, 확인필요면 반드시 있어야 한다 — 학생이 문구를 보고 다시 내기 때문이다. + * 반려 사유는 확인필요일 때만 남기고, 확인필요면 반드시 있어야 한다 — 학생이 사유를 보고 다시 내기 때문이다. * 납부확인중으로 되돌리면 아직 처리 전이라는 뜻이므로 처리 시각도 지운다. */ - public void review(TransferStatus reviewedStatus, String adminNote, LocalDateTime now) { - validateAdminNote(reviewedStatus, adminNote); + public void review(TransferStatus reviewedStatus, String rejectReason, LocalDateTime now) { + validateRejectReason(reviewedStatus, rejectReason); this.status = reviewedStatus; - this.adminNote = reviewedStatus == TransferStatus.REJECTED ? adminNote.strip() : null; + this.rejectReason = reviewedStatus == TransferStatus.REJECTED ? rejectReason.strip() : null; this.reviewedAt = reviewedStatus == TransferStatus.PENDING ? null : now; } - // 확인필요는 학생에게 보여줄 안내 문구가 있어야 의미가 있다. 그 외 상태에서는 문구를 받아도 버린다 - private static void validateAdminNote(TransferStatus status, String adminNote) { + // 확인필요는 학생에게 보여줄 반려 사유가 있어야 의미가 있다. 그 외 상태에서는 사유를 받아도 버린다 + private static void validateRejectReason(TransferStatus status, String rejectReason) { if (status != TransferStatus.REJECTED) { return; } - if (adminNote == null || adminNote.isBlank() || adminNote.strip().length() > MAX_ADMIN_NOTE_LENGTH) { - throw new BusinessException(FeeErrorCode.INVALID_ADMIN_NOTE); + if (rejectReason == null || rejectReason.isBlank() || rejectReason.strip().length() > MAX_REJECT_REASON_LENGTH) { + throw new BusinessException(FeeErrorCode.INVALID_REJECT_REASON); } } } 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 b8a499f0..143c69a9 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 @@ -25,9 +25,9 @@ public interface StudentTransferStatusService { PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset); /** - * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남고, - * 확인필요인데 비어 있거나 255자를 넘으면 {@code INVALID_ADMIN_NOTE}다. + * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. rejectReason은 확인필요일 때만 남고, + * 확인필요인데 비어 있거나 255자를 넘으면 {@code INVALID_REJECT_REASON}다. * 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다. */ - StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote); + StudentTransferStatus review(Long transferStatusId, TransferStatus status, String rejectReason); } 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 ce73be6e..3b501e89 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 @@ -51,12 +51,12 @@ public PageResult search(TransferStatus status, List new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND)); validateLatest(transferStatus); - transferStatus.review(status, adminNote, LocalDateTime.now(DateUtil.KST)); + transferStatus.review(status, rejectReason, LocalDateTime.now(DateUtil.KST)); return studentTransferStatusRepository.save(transferStatus); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java index b6772a83..7b242e93 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java @@ -52,7 +52,7 @@ public class StudentTransferStatusJpaEntity extends BaseTimeEntity { private Long amount; @Column(name = "admin_note", length = 255) - private String adminNote; + private String rejectReason; @Column(name = "requested_at") private LocalDateTime requestedAt; @@ -67,7 +67,7 @@ private StudentTransferStatusJpaEntity(StudentTransferStatus request) { this.remainingSemesters = request.getRemainingSemesters(); this.proofImageKey = request.getProofImageKey(); this.amount = request.getAmount(); - this.adminNote = request.getAdminNote(); + this.rejectReason = request.getRejectReason(); this.requestedAt = request.getRequestedAt(); this.reviewedAt = request.getReviewedAt(); } @@ -78,6 +78,6 @@ public static StudentTransferStatusJpaEntity from(StudentTransferStatus request) public StudentTransferStatus toDomain() { return StudentTransferStatus.of(id, memberId, status, remainingSemesters, proofImageKey, - amount, adminNote, requestedAt, reviewedAt, getCreatedAt(), getUpdatedAt()); + amount, rejectReason, requestedAt, reviewedAt, getCreatedAt(), getUpdatedAt()); } } From 497a897ca35a24dacb5d630e9d71fede13cd36a2 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: Tue, 27 Oct 2026 18:36:55 +0900 Subject: [PATCH 13/18] =?UTF-8?q?refactor:=20=EB=82=A9=EB=B6=80=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=8B=9C=EA=B0=81=EC=9D=84=20requested=5F?= =?UTF-8?q?at=20=EB=8C=80=EC=8B=A0=20created=5Fat=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=93=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/fee/domain/StudentTransferStatus.java | 14 ++++++++------ .../impl/StudentTransferStatusServiceImpl.java | 4 ++-- .../db/welfare/StudentTransferStatusJpaEntity.java | 6 +----- .../StudentTransferStatusJpaRepository.java | 2 +- 4 files changed, 12 insertions(+), 14 deletions(-) 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 8ac0a50d..2ed5f093 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 @@ -25,7 +25,6 @@ public class StudentTransferStatus { private Long amount; // 관리자가 확인필요로 처리하며 학생에게 남긴 반려 사유. REJECTED일 때만 값이 있다 private String rejectReason; - private LocalDateTime requestedAt; private LocalDateTime reviewedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -38,11 +37,10 @@ public static StudentTransferStatus create( Long memberId, int remainingSemesters, String proofImageKey, - long amount, - LocalDateTime now + long amount ) { return new StudentTransferStatus(null, memberId, TransferStatus.PENDING, remainingSemesters, proofImageKey, - amount, null, now, null, null, null); + amount, null, null, null, null); } public static StudentTransferStatus of( @@ -53,13 +51,12 @@ public static StudentTransferStatus of( String proofImageKey, Long amount, String rejectReason, - LocalDateTime requestedAt, LocalDateTime reviewedAt, LocalDateTime createdAt, LocalDateTime updatedAt ) { return new StudentTransferStatus(id, memberId, status, remainingSemesters, proofImageKey, - amount, rejectReason, requestedAt, reviewedAt, createdAt, updatedAt); + amount, rejectReason, reviewedAt, createdAt, updatedAt); } /** @@ -76,6 +73,11 @@ public void validateRequestable() { } } + // 요청마다 새 행을 만들므로 행이 생긴 시각이 곧 요청 시각이다 + public LocalDateTime getRequestedAt() { + return createdAt; + } + /** 납부 확인 시각. 납부완료일 때의 처리 시각이다. */ public LocalDateTime getConfirmedAt() { return status == TransferStatus.PAID ? reviewedAt : null; 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 3b501e89..b61bc8d1 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 @@ -36,8 +36,8 @@ public StudentTransferStatus submit(Long memberId, FeeSubmitCommand command, lon .ifPresent(StudentTransferStatus::validateRequestable); // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V26)가 두 번째 저장을 막는다 - StudentTransferStatus transferStatus = StudentTransferStatus.create(memberId, - command.remainingSemesters().value(), command.proofImageKey(), amount, LocalDateTime.now(DateUtil.KST)); + StudentTransferStatus transferStatus = StudentTransferStatus.create( + memberId, command.remainingSemesters().value(), command.proofImageKey(), amount); return studentTransferStatusRepository.save(transferStatus); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java index 7b242e93..73a4ed39 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java @@ -54,9 +54,6 @@ public class StudentTransferStatusJpaEntity extends BaseTimeEntity { @Column(name = "admin_note", length = 255) private String rejectReason; - @Column(name = "requested_at") - private LocalDateTime requestedAt; - @Column(name = "reviewed_at") private LocalDateTime reviewedAt; @@ -68,7 +65,6 @@ private StudentTransferStatusJpaEntity(StudentTransferStatus request) { this.proofImageKey = request.getProofImageKey(); this.amount = request.getAmount(); this.rejectReason = request.getRejectReason(); - this.requestedAt = request.getRequestedAt(); this.reviewedAt = request.getReviewedAt(); } @@ -78,6 +74,6 @@ public static StudentTransferStatusJpaEntity from(StudentTransferStatus request) public StudentTransferStatus toDomain() { return StudentTransferStatus.of(id, memberId, status, remainingSemesters, proofImageKey, - amount, rejectReason, requestedAt, reviewedAt, getCreatedAt(), getUpdatedAt()); + amount, rejectReason, reviewedAt, getCreatedAt(), getUpdatedAt()); } } 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 365e4192..2a4896ee 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 @@ -11,7 +11,7 @@ /** * 납부 요청은 이력으로 쌓인다. 회원의 지금 상태는 PK가 가장 큰 행이다 — 같은 회원에게 반려(REJECTED)가 여러 개 - * 쌓이고 최신 행 자체가 반려일 수도 있어 상태로는 고를 수 없고, requested_at은 초 단위라 같은 초에 겹칠 수 있다. + * 쌓이고 최신 행 자체가 반려일 수도 있어 상태로는 고를 수 없고, created_at은 초 단위라 같은 초에 겹칠 수 있다. */ public interface StudentTransferStatusJpaRepository extends JpaRepository { From 3c2bd38e8342f350ec56b574d54b3ef8eaef8663 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: Tue, 27 Oct 2026 18:39:15 +0900 Subject: [PATCH 14/18] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=EC=9D=84?= =?UTF-8?q?=20=EC=B5=9C=EC=A2=85=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EC=84=B8=20=EA=B0=9C=EB=A1=9C=20=ED=95=A9=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fee/domain/StudentTransferStatus.java | 2 +- .../domain/fee/domain/TransferStatus.java | 3 +- .../StudentTransferStatusServiceImpl.java | 2 +- .../stream/db/file/FileJpaRepository.java | 2 +- .../StudentTransferStatusJpaEntity.java | 16 ++++----- .../V20__add_fee_submission_columns.sql | 12 ------- ...sert_default_student_transfer_statuses.sql | 12 ------- .../V23__add_file_key_index_to_files.sql | 3 ++ .../V23__rename_needs_check_to_rejected.sql | 5 --- ...dd_amount_to_student_transfer_statuses.sql | 5 --- ...ve_unique_to_student_transfer_statuses.sql | 33 +++++++++++++++++++ ...t_unpaid_transfer_statuses_to_rejected.sql | 12 +++++++ .../V25__remove_unpaid_transfer_statuses.sql | 10 ------ ..._multiple_transfer_requests_per_member.sql | 17 ---------- 14 files changed, 61 insertions(+), 73 deletions(-) delete mode 100644 infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql delete mode 100644 infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql create mode 100644 infrastructure/db/src/main/resources/db/migration/V23__add_file_key_index_to_files.sql delete mode 100644 infrastructure/db/src/main/resources/db/migration/V23__rename_needs_check_to_rejected.sql delete mode 100644 infrastructure/db/src/main/resources/db/migration/V24__add_amount_to_student_transfer_statuses.sql create mode 100644 infrastructure/db/src/main/resources/db/migration/V24__add_request_columns_and_active_unique_to_student_transfer_statuses.sql create mode 100644 infrastructure/db/src/main/resources/db/migration/V25__convert_unpaid_transfer_statuses_to_rejected.sql delete mode 100644 infrastructure/db/src/main/resources/db/migration/V25__remove_unpaid_transfer_statuses.sql delete mode 100644 infrastructure/db/src/main/resources/db/migration/V26__allow_multiple_transfer_requests_per_member.sql 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 2ed5f093..b635751d 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 @@ -12,7 +12,7 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class StudentTransferStatus { - // admin_note 컬럼 길이 + // reject_reason 컬럼 길이 private static final int MAX_REJECT_REASON_LENGTH = 255; private Long id; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java index 62cafa66..8499e531 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java @@ -2,7 +2,8 @@ import kr.ac.kookmin.stream.common.BusinessException; -// 납부 요청의 상태. 한 번도 요청하지 않은 회원(미납부)은 행이 없으므로 여기에 상태가 없다 +// 납부 요청의 상태. 한 번도 요청하지 않은 회원(미납부)은 행이 없으므로 여기에 상태가 없다. +// 진행 중·완료(PENDING, PAID)는 V24의 active_member_id 식에 그대로 적혀 있어, 상태를 더하거나 바꾸면 그 식도 같이 바꿔야 한다 public enum TransferStatus { PENDING, PAID, 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 b61bc8d1..a9883006 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 @@ -35,7 +35,7 @@ public StudentTransferStatus submit(Long memberId, FeeSubmitCommand command, lon studentTransferStatusRepository.findLatestByMemberId(memberId) .ifPresent(StudentTransferStatus::validateRequestable); - // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V26)가 두 번째 저장을 막는다 + // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V24)가 두 번째 저장을 막는다 StudentTransferStatus transferStatus = StudentTransferStatus.create( memberId, command.remainingSemesters().value(), command.proofImageKey(), amount); return studentTransferStatusRepository.save(transferStatus); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java index cb534fde..76f04881 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java @@ -7,6 +7,6 @@ public interface FileJpaRepository extends JpaRepository { List findAllByIdIn(List ids); - // file_key는 TEXT라 idx_files_file_key가 prefix(255) 인덱스다(V20) + // file_key는 TEXT라 idx_files_file_key가 prefix(255) 인덱스다(V23) Optional findByFileKey(String fileKey); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java index 73a4ed39..b7cee536 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java @@ -20,12 +20,12 @@ * 현재는 학생회장/총무부장 등 소수 인원만 처리 권한을 가져 낙관적 락(@Version)을 쓰지 않는다. * 처리 권한자가 여러 명으로 늘어나면 동시 처리 경합을 막기 위해 @Version 도입을 검토한다. */ -// uk_student_transfer_statuses_active_member_id(V26)는 generated column active_member_id에 걸려 있다. -// Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. @Entity +// uk_student_transfer_statuses_active_member_id(V24)는 generated column active_member_id에 걸려 있다. +// Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. @Table( name = "student_transfer_statuses", - indexes = @Index(name = "idx_student_transfer_statuses_member_id_id", columnList = "member_id, student_transfer_status_id") + indexes = @Index(name = "idx_student_transfer_statuses_member_id", columnList = "member_id") ) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class StudentTransferStatusJpaEntity extends BaseTimeEntity { @@ -45,13 +45,13 @@ public class StudentTransferStatusJpaEntity extends BaseTimeEntity { @Column(name = "remaining_semesters") private Integer remainingSemesters; - @Column(name = "proof_image_key", length = 255) - private String proofImageKey; - @Column(name = "amount") private Long amount; - @Column(name = "admin_note", length = 255) + @Column(name = "proof_image_key") + private String proofImageKey; + + @Column(name = "reject_reason") private String rejectReason; @Column(name = "reviewed_at") @@ -62,8 +62,8 @@ private StudentTransferStatusJpaEntity(StudentTransferStatus request) { this.memberId = request.getMemberId(); this.status = request.getStatus(); this.remainingSemesters = request.getRemainingSemesters(); - this.proofImageKey = request.getProofImageKey(); this.amount = request.getAmount(); + this.proofImageKey = request.getProofImageKey(); this.rejectReason = request.getRejectReason(); this.reviewedAt = request.getReviewedAt(); } diff --git a/infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql b/infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql deleted file mode 100644 index d289fbc1..00000000 --- a/infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 학생이 제출하는 납부 정보(남은 학기 수·학점이수현황 캡처)와, 관리자 안내 문구·송금 확인 요청 시각을 담는다. --- status에 NEEDS_CHECK(확인필요)가 추가된다 — UNPAID / PENDING / PAID / NEEDS_CHECK. --- 기존 행은 제출 전으로 보므로 전부 NULL을 허용한다. -ALTER TABLE student_transfer_statuses - ADD COLUMN remaining_semesters INT NULL, - ADD COLUMN proof_image_key VARCHAR(255) NULL, - ADD COLUMN admin_note VARCHAR(255) NULL, - ADD COLUMN requested_at DATETIME NULL; - - --- proof_image_key로 파일을 찾기 위한 인덱스. files.file_key가 TEXT라 prefix 길이를 지정한다. -CREATE INDEX idx_files_file_key ON files (file_key(255)); 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 deleted file mode 100644 index fc6b107c..00000000 --- a/infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 납부 상태 행은 회원이 만들어질 때(첫 로그인) 함께 만든다. 그 전에 들어온 회원에게도 납부 전 행을 만들어 둔다. --- 관리자 납부자 목록이 "납부 전" 회원까지 보여주려면 모든 회원에게 행이 있어야 한다. --- 행이 없는 회원을 목록에 넣으려면 회원 기준으로 조회해야 하는데, 그러면 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 - ); diff --git a/infrastructure/db/src/main/resources/db/migration/V23__add_file_key_index_to_files.sql b/infrastructure/db/src/main/resources/db/migration/V23__add_file_key_index_to_files.sql new file mode 100644 index 00000000..036ac77f --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V23__add_file_key_index_to_files.sql @@ -0,0 +1,3 @@ +-- 학생회비 납부 요청은 인증 이미지를 업로드 URL 발급 때 받은 file_key로 가리킨다(WHERE file_key = ?). +-- file_key가 TEXT라 prefix 길이를 지정한다. 키는 files/{uuid}.{확장자} 형태라 255자 안에 모두 들어간다. +CREATE INDEX idx_files_file_key ON files (file_key(255)); diff --git a/infrastructure/db/src/main/resources/db/migration/V23__rename_needs_check_to_rejected.sql b/infrastructure/db/src/main/resources/db/migration/V23__rename_needs_check_to_rejected.sql deleted file mode 100644 index a60997ff..00000000 --- a/infrastructure/db/src/main/resources/db/migration/V23__rename_needs_check_to_rejected.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 확인필요 상태의 이름을 NEEDS_CHECK에서 REJECTED로 바꾼다. 화면 표기(확인필요)는 그대로다. --- 반려된 학생이 다시 납부하는 흐름으로 바뀌므로(다음 PR) 상태 이름을 하는 일에 맞춘다. -UPDATE student_transfer_statuses -SET status = 'REJECTED' -WHERE status = 'NEEDS_CHECK'; diff --git a/infrastructure/db/src/main/resources/db/migration/V24__add_amount_to_student_transfer_statuses.sql b/infrastructure/db/src/main/resources/db/migration/V24__add_amount_to_student_transfer_statuses.sql deleted file mode 100644 index a4a9cfd1..00000000 --- a/infrastructure/db/src/main/resources/db/migration/V24__add_amount_to_student_transfer_statuses.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 학생에게 안내한 납부 금액을 요청 시점 값으로 남긴다(남은 학기 수 × 그때의 학기당 회비). --- 운영진이 학기당 회비를 바꿔도 이미 들어온 요청의 금액은 바뀌지 않게 하려는 것이다. --- 이 컬럼이 생기기 전의 행은 금액을 모르므로 NULL을 허용한다. 계산 결과가 long이라 BIGINT로 둔다(ddl-auto validate). -ALTER TABLE student_transfer_statuses - ADD COLUMN amount BIGINT NULL AFTER remaining_semesters; diff --git a/infrastructure/db/src/main/resources/db/migration/V24__add_request_columns_and_active_unique_to_student_transfer_statuses.sql b/infrastructure/db/src/main/resources/db/migration/V24__add_request_columns_and_active_unique_to_student_transfer_statuses.sql new file mode 100644 index 00000000..01fcc7dc --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V24__add_request_columns_and_active_unique_to_student_transfer_statuses.sql @@ -0,0 +1,33 @@ +-- 학생회비 납부 요청을 회원당 한 행(현재 상태)에서 요청당 한 행(이력)으로 바꾸고, 요청에 담기는 값을 추가한다. +-- 회원의 지금 상태는 최신 행(PK가 가장 큰 행)의 상태이고, 요청 시각은 행이 생긴 시각(created_at)으로 갈음한다. +-- +-- 새 컬럼은 이 마이그레이션 전에 생긴 행에는 값이 없어 NULL을 허용한다. +-- remaining_semesters: 학생이 입력한 남은 학기 수(1~8) +-- amount: 요청 시점에 안내한 납부 금액(남은 학기 수 × 그때의 학기당 회비). 계산 결과가 long이라 BIGINT로 둔다 +-- proof_image_key: 남은 학기 수의 근거로 올린 학점이수현황 캡처의 file_key +-- reject_reason: 운영진이 확인필요(REJECTED)로 돌려보내며 학생에게 남긴 반려 사유 +ALTER TABLE student_transfer_statuses + ADD COLUMN remaining_semesters INT NULL AFTER status, + ADD COLUMN amount BIGINT NULL AFTER remaining_semesters, + ADD COLUMN proof_image_key VARCHAR(255) NULL AFTER amount, + ADD COLUMN reject_reason VARCHAR(255) NULL AFTER proof_image_key; + +-- 상태는 PENDING / PAID / REJECTED다. 한 번도 요청하지 않은 회원은 행이 없으므로 기본값 UNPAID를 없앤다. +-- DEFAULT를 남기면 INSERT가 status를 빠뜨려도 조용히 UNPAID로 들어간다(V10과 같은 이유). 기존 UNPAID 행은 V25가 정리한다. +ALTER TABLE student_transfer_statuses + ALTER COLUMN status DROP DEFAULT; + +-- 최신 요청 조회(WHERE member_id = ? ORDER BY student_transfer_status_id DESC)용. +-- InnoDB가 세컨더리 인덱스 끝에 PK를 붙이므로 member_id만으로 정렬까지 받쳐진다. +-- 회원당 유니크를 지우기 전에 먼저 만들어 member_id 조회가 인덱스를 잃는 순간이 없게 한다. +CREATE INDEX idx_student_transfer_statuses_member_id ON student_transfer_statuses (member_id); + +DROP INDEX uk_student_transfer_statuses_member_id ON student_transfer_statuses; + +-- 진행 중(PENDING)·완료(PAID) 요청은 회원당 하나만 허용한다. 반려(REJECTED) 행은 NULL이 되어 몇 개든 남는다 +-- (MySQL 유니크는 NULL을 여러 개 허용한다). 같은 회원의 동시 요청은 이 유니크가 두 번째 INSERT를 막는다. +ALTER TABLE student_transfer_statuses + ADD COLUMN active_member_id BIGINT + GENERATED ALWAYS AS (IF(status IN ('PENDING', 'PAID'), member_id, NULL)) VIRTUAL; +CREATE UNIQUE INDEX uk_student_transfer_statuses_active_member_id + ON student_transfer_statuses (active_member_id); diff --git a/infrastructure/db/src/main/resources/db/migration/V25__convert_unpaid_transfer_statuses_to_rejected.sql b/infrastructure/db/src/main/resources/db/migration/V25__convert_unpaid_transfer_statuses_to_rejected.sql new file mode 100644 index 00000000..e6bc94d9 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V25__convert_unpaid_transfer_statuses_to_rejected.sql @@ -0,0 +1,12 @@ +-- 이전 흐름에서는 운영진이 반려하면 상태를 UNPAID로 되돌렸다(처리 시각 reviewed_at은 남는다). +-- 이제 반려는 REJECTED이고, 한 번도 요청하지 않은 회원은 행이 없으므로 기존 UNPAID 행을 정리한다. +-- 처리된 적 있는 행은 반려된 요청이라 REJECTED로 바꾸고, 새로 반려할 때처럼 학생에게 보여줄 반려 사유를 채운다. +UPDATE student_transfer_statuses +SET status = 'REJECTED', + reject_reason = '운영진이 납부를 확인하지 못했어요. 학생회에 문의해 주세요.' +WHERE status = 'UNPAID' + AND reviewed_at IS NOT NULL; + +-- 처리된 적 없는 UNPAID 행은 원래 생기지 않지만, 있다면 요청이 아니므로 지운다. +DELETE FROM student_transfer_statuses +WHERE status = 'UNPAID'; diff --git a/infrastructure/db/src/main/resources/db/migration/V25__remove_unpaid_transfer_statuses.sql b/infrastructure/db/src/main/resources/db/migration/V25__remove_unpaid_transfer_statuses.sql deleted file mode 100644 index 8b4c67aa..00000000 --- a/infrastructure/db/src/main/resources/db/migration/V25__remove_unpaid_transfer_statuses.sql +++ /dev/null @@ -1,10 +0,0 @@ --- 미납부는 이제 행이 없는 상태다. 납부 정보 제출과 송금 확인 요청이 한 번으로 합쳐져 "제출만 한" 상태가 없어졌다. --- UNPAID 행 중 운영진이 처리한 적 있는 행(reviewed_at이 있음)은 돌려보낸 요청이었으므로 REJECTED로 남기고, --- 처리된 적 없는 행(V21이 전 회원에게 만든 행, 제출만 하고 확인 요청을 안 한 행)은 지운다. -UPDATE student_transfer_statuses -SET status = 'REJECTED' -WHERE status = 'UNPAID' - AND reviewed_at IS NOT NULL; - -DELETE FROM student_transfer_statuses -WHERE status = 'UNPAID'; diff --git a/infrastructure/db/src/main/resources/db/migration/V26__allow_multiple_transfer_requests_per_member.sql b/infrastructure/db/src/main/resources/db/migration/V26__allow_multiple_transfer_requests_per_member.sql deleted file mode 100644 index 9f6ff9df..00000000 --- a/infrastructure/db/src/main/resources/db/migration/V26__allow_multiple_transfer_requests_per_member.sql +++ /dev/null @@ -1,17 +0,0 @@ --- 납부 요청을 회원당 한 행(현재 상태)에서 요청당 한 행(이력)으로 바꾼다. 반려된 요청도 지우지 않고 남긴다. --- 회원의 지금 상태는 최신 행(PK가 가장 큰 행)의 상태다. - --- 최신 요청 조회용. 회원당 유니크 인덱스를 지우기 전에 먼저 만들어 member_id 조회가 인덱스를 잃지 않게 한다. -CREATE INDEX idx_student_transfer_statuses_member_id_id - ON student_transfer_statuses (member_id, student_transfer_status_id); - -DROP INDEX uk_student_transfer_statuses_member_id ON student_transfer_statuses; - --- 진행 중(PENDING)·완료(PAID) 요청은 회원당 하나만 허용한다. 반려(REJECTED) 행은 NULL이 되어 몇 개든 남는다 --- (MySQL 유니크는 NULL을 여러 개 허용한다). 같은 회원의 동시 요청은 이 유니크가 두 번째 INSERT를 막는다. --- 낙관적 락으로는 막을 수 없다 — 두 요청 모두 기존 행을 고치지 않고 새 행을 넣기만 하고, 첫 요청은 행 자체가 없다. -ALTER TABLE student_transfer_statuses - ADD COLUMN active_member_id BIGINT - GENERATED ALWAYS AS (IF(status IN ('PENDING', 'PAID'), member_id, NULL)) VIRTUAL; -CREATE UNIQUE INDEX uk_student_transfer_statuses_active_member_id - ON student_transfer_statuses (active_member_id); From 2eb5ef13089c2961f06954b1eea0719edf793a11 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: Tue, 27 Oct 2026 18:41:02 +0900 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20=EA=B0=80=EC=9E=85=EC=9D=84=20?= =?UTF-8?q?=EB=A7=88=EC=B9=9C=20=ED=9A=8C=EC=9B=90=20ID=EB=A5=BC=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=EC=97=90=20=EC=A1=B0=ED=9A=8C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberTermAgreementRepository.java | 1 + .../domain/member/service/MemberService.java | 6 +++++ .../member/service/MemberTermService.java | 4 +++ .../service/impl/MemberServiceImpl.java | 13 ++++++++++ .../service/impl/MemberTermServiceImpl.java | 26 ++++++++++++++++--- .../MemberTermAgreementJpaRepository.java | 1 + .../MemberTermAgreementRepositoryImpl.java | 7 +++++ 7 files changed, 55 insertions(+), 3 deletions(-) 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 4042dbe5..a115d0ee 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,7 @@ import java.util.List; import java.util.Map; +import java.util.Set; import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.member.domain.member.domain.MemberPhoneNumberUpdateCommand; import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand; @@ -20,6 +21,11 @@ public interface MemberService { Member registerOrUpdateByStudentId(MemberProfileCommand command); Member signUp(Long memberId, MemberSignUpCommand command); boolean isSignedUp(Long memberId); + /** + * 주어진 회원 중 가입(전화번호 등록 + 필수 약관 동의)을 마친 회원 ID. 기준은 {@link #isSignedUp}과 같고 탈퇴한 회원은 빠진다. + * 목록에서 회원마다 isSignedUp을 부르지 않도록 한 번에 조회한다. + */ + Set findSignedUpIds(List memberIds); void updateFcmToken(Long memberId, String fcmToken); void updatePhoneNumber(Long memberId, MemberPhoneNumberUpdateCommand 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..df7c521e 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,13 @@ 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. 기준은 hasAgreedRequiredTerms와 같다 + Set findIdsAgreedRequiredTerms(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 16babadf..778bdc63 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 @@ -3,6 +3,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; @@ -84,6 +85,18 @@ public boolean isSignedUp(Long memberId) { return isSignedUp(getById(memberId)); } + @Override + public Set findSignedUpIds(List memberIds) { + if (memberIds.isEmpty()) { + return Set.of(); + } + List phoneRegisteredIds = findAllByIds(memberIds).stream() + .filter(Member::hasPhoneNumber) + .map(Member::getId) + .toList(); + return memberTermService.findIdsAgreedRequiredTerms(phoneRegisteredIds); + } + // 한 기기를 다른 계정으로 쓰게 되면 이전 회원의 알림이 새 사용자에게 가지 않도록, 다른 회원에게 남은 같은 토큰을 지운다. // 이미 중복이 들어 있는 데이터도 정리하려고 자기 토큰이 그대로여도 매번 지운다 @Override 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..57a5b026 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 @@ -29,9 +29,23 @@ public boolean hasAgreedRequiredTerms(Long memberId) { .filter(MemberTermAgreement::isAgreed) .map(MemberTermAgreement::getTermType) .collect(Collectors.toSet()); - return Arrays.stream(TermType.values()) - .filter(TermType::required) - .allMatch(agreedTermTypes::contains); + return containsRequiredTerms(agreedTermTypes); + } + + @Override + public Set findIdsAgreedRequiredTerms(List memberIds) { + if (memberIds.isEmpty()) { + return Set.of(); + } + Map> agreedTermTypesByMemberId = memberTermAgreementRepository.findAllByMemberIdIn(memberIds) + .stream() + .filter(MemberTermAgreement::isAgreed) + .collect(Collectors.groupingBy( + MemberTermAgreement::getMemberId, + Collectors.mapping(MemberTermAgreement::getTermType, Collectors.toSet()))); + return memberIds.stream() + .filter(memberId -> containsRequiredTerms(agreedTermTypesByMemberId.getOrDefault(memberId, Set.of()))) + .collect(Collectors.toSet()); } // 요청에 없는 약관은 동의하지 않은 것으로 보고 기록을 남기지 않는다 @@ -50,6 +64,12 @@ public void agree(Long memberId, Map termAgreements) { memberTermAgreementRepository.saveAll(agreements); } + private boolean containsRequiredTerms(Set agreedTermTypes) { + return Arrays.stream(TermType.values()) + .filter(TermType::required) + .allMatch(agreedTermTypes::contains); + } + private void validateRequiredTermsAgreed(Map termAgreements) { boolean requiredTermsAgreed = Arrays.stream(TermType.values()) .filter(TermType::required) 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..9f631be3 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,5 @@ 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..de529f7e 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 @@ -19,6 +19,13 @@ public List findAllByMemberId(Long memberId) { .toList(); } + @Override + public List findAllByMemberIdIn(List memberIds) { + return memberTermAgreementJpaRepository.findAllByMemberIdIn(memberIds).stream() + .map(MemberTermAgreementJpaEntity::toDomain) + .toList(); + } + @Override public void saveAll(List agreements) { memberTermAgreementJpaRepository.saveAll(agreements.stream() From 6b98eb3e6d01704c2cfef7aa81dbb41f478b2a56 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: Tue, 27 Oct 2026 18:41:36 +0900 Subject: [PATCH 16/18] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EC=9D=98=20=EC=9A=94=EC=B2=AD=EC=9D=B4=20=EC=9E=88?= =?UTF-8?q?=EC=96=B4=EB=8F=84=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=82=A9?= =?UTF-8?q?=EB=B6=80=EC=9E=90=20=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EC=97=B4?= =?UTF-8?q?=EB=A6=AC=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/welfare/fee/AdminFeeController.java | 5 +++-- .../fee/response/AdminFeeSearchResponse.java | 19 ++++++++----------- .../fee/usecase/AdminFeeSearchUseCase.java | 16 ++++++++-------- .../welfare/fee/usecase/MemberFeeStatus.java | 10 ++++++++++ 4 files changed, 29 insertions(+), 21 deletions(-) create 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/AdminFeeController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeController.java index 9034dee1..92ad4b4b 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 @@ -10,6 +10,7 @@ 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; @@ -47,10 +48,10 @@ public ApiResponse> getRequests( @RequestParam(required = false) String keyword, @Valid @ModelAttribute PageParams pageParams ) { - PageResult result = adminFeeSearchUseCase.search( + PageResult result = adminFeeSearchUseCase.search( TransferStatus.from(status), keyword, pageParams.toOffset()); - return ApiResponse.success(PageResponse.from(result, response -> response)); + return ApiResponse.success(PageResponse.from(result, AdminFeeSearchResponse::from)); } @Override 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 b1bb7404..e1e8a7b9 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,6 +1,7 @@ package kr.ac.kookmin.stream.api.admin.welfare.fee.response; import java.time.LocalDateTime; +import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.MemberFeeStatus; 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; @@ -15,7 +16,7 @@ public record AdminFeeSearchResponse( String name, String studentId, String department, - boolean isStreamMember, + boolean isSignedUp, String feeStatus, Integer remainingSemesters, Long transferAmount, @@ -24,22 +25,18 @@ public record AdminFeeSearchResponse( LocalDateTime confirmedAt, String rejectReason ) { - /** - * @param member 탈퇴(소프트 삭제)한 회원의 행이면 null — 이름·학번·학과를 알 수 없어 표시용 placeholder를 쓴다 - * @param signedUp 회원가입(전화번호 + 필수 약관 동의)을 마쳤는지 - */ - public static AdminFeeSearchResponse of( - StudentTransferStatus transferStatus, - Member member, - boolean signedUp - ) { + // member가 null이면 탈퇴(소프트 삭제)한 회원의 행이다 — 이름·학번·학과를 알 수 없어 표시용 placeholder를 쓴다. + // signedUp은 회원가입(전화번호 + 필수 약관 동의)을 마쳤는지다 + public static AdminFeeSearchResponse from(MemberFeeStatus memberFeeStatus) { + StudentTransferStatus transferStatus = memberFeeStatus.transferStatus(); + Member member = memberFeeStatus.member(); return new AdminFeeSearchResponse( transferStatus.getId(), transferStatus.getMemberId(), member != null ? member.getName() : ApiConstants.WITHDRAWN_MEMBER_LABEL, member != null ? member.getStudentId() : null, member != null ? member.getDepartment().displayName() : null, - signedUp, + memberFeeStatus.signedUp(), transferStatus.getStatus().name(), transferStatus.getRemainingSemesters(), // 요청 시점에 남긴 금액이다. 운영진이 학기당 회비를 바꿔도 이미 들어온 요청의 금액은 그대로 보인다 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 cc4edc12..23bff7d3 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,7 @@ import java.util.List; import java.util.Map; -import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse; +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; @@ -19,7 +19,8 @@ * 페이징과 상태 필터는 회비 쪽에서 한다. 납부를 요청한 회원만 행이 있으므로 목록도 요청한 회원만 나온다 * (한 번도 요청하지 않은 미납부 회원은 관리자 화면에서 다루지 않는다). *

- * member 쪽은 이미 공개된 조회 메서드만 쓴다 — 검색은 {@code searchIdsByKeyword}, 회원 정보는 {@code getMapByIds}. + * member 쪽은 공개 조회 메서드만 쓴다 — 검색은 {@code searchIdsByKeyword}, 회원 정보는 {@code getMapByIds}, + * 가입 여부는 {@code findSignedUpIds}. 회원 정보와 가입 여부는 한 페이지의 회원을 한 번에 조회한다. *

* 트랜잭션을 걸지 않는다. 쓰기가 없고, 조회 사이가 어긋나도 한 줄의 정보가 잠깐 최신이 아닐 뿐이다. */ @@ -30,7 +31,7 @@ public class AdminFeeSearchUseCase { private final StudentTransferStatusService studentTransferStatusService; private final MemberService memberService; - public PageResult search(TransferStatus status, String keyword, PageOffset pageOffset) { + public PageResult search(TransferStatus status, String keyword, PageOffset pageOffset) { List memberIds = null; if (keyword != null && !keyword.isBlank()) { memberIds = memberService.searchIdsByKeyword(keyword); @@ -42,15 +43,14 @@ public PageResult search(TransferStatus status, String k PageResult page = studentTransferStatusService.search(status, memberIds, pageOffset); List pagedMemberIds = page.content().stream().map(StudentTransferStatus::getMemberId).toList(); - // 탈퇴(소프트 삭제)한 회원은 빠지므로 그 회원의 행은 member가 null로 남는다 + // 탈퇴(소프트 삭제)한 회원은 둘 다에서 빠지므로 그 회원의 행은 member가 null, 가입 여부는 false로 남는다 Map membersById = memberService.getMapByIds(pagedMemberIds); + Set signedUpIds = memberService.findSignedUpIds(pagedMemberIds); - return page.map(transferStatus -> AdminFeeSearchResponse.of( + return page.map(transferStatus -> new MemberFeeStatus( transferStatus, membersById.get(transferStatus.getMemberId()), - // 가입 여부는 member가 아는 사실이라 member의 공개 메서드에 묻는다. - // 줄마다 한 번씩 부르므로 목록이 커지면 벌크 조회를 member 쪽에 요청할 자리다 - memberService.isSignedUp(transferStatus.getMemberId()) + signedUpIds.contains(transferStatus.getMemberId()) )); } } 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 new file mode 100644 index 00000000..47966e42 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/MemberFeeStatus.java @@ -0,0 +1,10 @@ +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 transferStatus, Member member, boolean signedUp) { +} From abb01229235e6b7d080cfa35d8c8da81e2400bfb 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: Tue, 27 Oct 2026 18:41:47 +0900 Subject: [PATCH 17/18] =?UTF-8?q?docs:=20=EC=A7=80=EC=9A=B4=20FeeAmountCal?= =?UTF-8?q?culator=EB=A5=BC=20=EA=B0=80=EB=A6=AC=ED=82=A4=EB=8D=98=20?= =?UTF-8?q?=EB=8C=80=EC=97=AC=20=EC=A3=BC=EC=84=9D=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/domain/rental/service/RentalTimeValidator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalTimeValidator.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalTimeValidator.java index 8e3f18af..8d7b2d15 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalTimeValidator.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalTimeValidator.java @@ -6,7 +6,7 @@ import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; // 대여 신청 시각(영업시간 10~17시, 점심시간 12~13시 제외, 이미 지난 시각 제외)을 검증한다. -// fee 도메인의 FeeAmountCalculator/TossTransferLinkGenerator와 같은 패턴 — 상태 없는 순수 검증 로직은 정적 유틸로 둔다. +// fee 도메인의 TossTransferLinkGenerator와 같은 패턴 — 상태 없는 순수 검증 로직은 정적 유틸로 둔다. public final class RentalTimeValidator { private static final LocalTime OPEN = LocalTime.of(10, 0); From affbc86e15e6389d347638cd31f3cb046dcb1132 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, 29 Oct 2026 19:57:48 +0900 Subject: [PATCH 18/18] =?UTF-8?q?chore:=20Flyway=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V23 → V21 add_file_key_index_to_files - V24 → V22 add_request_columns_and_active_unique_to_student_transfer_statuses - V25 → V23 convert_unpaid_transfer_statuses_to_rejected - 주석의 버전 참조도 새 번호로 수정 --- .../stream/welfare/domain/fee/domain/TransferStatus.java | 2 +- .../fee/service/impl/StudentTransferStatusServiceImpl.java | 2 +- .../java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java | 2 +- .../stream/db/welfare/StudentTransferStatusJpaEntity.java | 2 +- ..._index_to_files.sql => V21__add_file_key_index_to_files.sql} | 0 ..._columns_and_active_unique_to_student_transfer_statuses.sql} | 2 +- ...ql => V23__convert_unpaid_transfer_statuses_to_rejected.sql} | 0 7 files changed, 5 insertions(+), 5 deletions(-) rename infrastructure/db/src/main/resources/db/migration/{V23__add_file_key_index_to_files.sql => V21__add_file_key_index_to_files.sql} (100%) rename infrastructure/db/src/main/resources/db/migration/{V24__add_request_columns_and_active_unique_to_student_transfer_statuses.sql => V22__add_request_columns_and_active_unique_to_student_transfer_statuses.sql} (98%) rename infrastructure/db/src/main/resources/db/migration/{V25__convert_unpaid_transfer_statuses_to_rejected.sql => V23__convert_unpaid_transfer_statuses_to_rejected.sql} (100%) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java index 8499e531..b8c611dc 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java @@ -3,7 +3,7 @@ import kr.ac.kookmin.stream.common.BusinessException; // 납부 요청의 상태. 한 번도 요청하지 않은 회원(미납부)은 행이 없으므로 여기에 상태가 없다. -// 진행 중·완료(PENDING, PAID)는 V24의 active_member_id 식에 그대로 적혀 있어, 상태를 더하거나 바꾸면 그 식도 같이 바꿔야 한다 +// 진행 중·완료(PENDING, PAID)는 V22의 active_member_id 식에 그대로 적혀 있어, 상태를 더하거나 바꾸면 그 식도 같이 바꿔야 한다 public enum TransferStatus { PENDING, PAID, 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 a9883006..9025de19 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 @@ -35,7 +35,7 @@ public StudentTransferStatus submit(Long memberId, FeeSubmitCommand command, lon studentTransferStatusRepository.findLatestByMemberId(memberId) .ifPresent(StudentTransferStatus::validateRequestable); - // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V24)가 두 번째 저장을 막는다 + // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V22)가 두 번째 저장을 막는다 StudentTransferStatus transferStatus = StudentTransferStatus.create( memberId, command.remainingSemesters().value(), command.proofImageKey(), amount); return studentTransferStatusRepository.save(transferStatus); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java index 76f04881..c0a056ea 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java @@ -7,6 +7,6 @@ public interface FileJpaRepository extends JpaRepository { List findAllByIdIn(List ids); - // file_key는 TEXT라 idx_files_file_key가 prefix(255) 인덱스다(V23) + // file_key는 TEXT라 idx_files_file_key가 prefix(255) 인덱스다(V21) Optional findByFileKey(String fileKey); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java index b7cee536..62cb873d 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java @@ -21,7 +21,7 @@ * 처리 권한자가 여러 명으로 늘어나면 동시 처리 경합을 막기 위해 @Version 도입을 검토한다. */ @Entity -// uk_student_transfer_statuses_active_member_id(V24)는 generated column active_member_id에 걸려 있다. +// uk_student_transfer_statuses_active_member_id(V22)는 generated column active_member_id에 걸려 있다. // Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다. @Table( name = "student_transfer_statuses", diff --git a/infrastructure/db/src/main/resources/db/migration/V23__add_file_key_index_to_files.sql b/infrastructure/db/src/main/resources/db/migration/V21__add_file_key_index_to_files.sql similarity index 100% rename from infrastructure/db/src/main/resources/db/migration/V23__add_file_key_index_to_files.sql rename to infrastructure/db/src/main/resources/db/migration/V21__add_file_key_index_to_files.sql diff --git a/infrastructure/db/src/main/resources/db/migration/V24__add_request_columns_and_active_unique_to_student_transfer_statuses.sql b/infrastructure/db/src/main/resources/db/migration/V22__add_request_columns_and_active_unique_to_student_transfer_statuses.sql similarity index 98% rename from infrastructure/db/src/main/resources/db/migration/V24__add_request_columns_and_active_unique_to_student_transfer_statuses.sql rename to infrastructure/db/src/main/resources/db/migration/V22__add_request_columns_and_active_unique_to_student_transfer_statuses.sql index 01fcc7dc..ce8b8664 100644 --- a/infrastructure/db/src/main/resources/db/migration/V24__add_request_columns_and_active_unique_to_student_transfer_statuses.sql +++ b/infrastructure/db/src/main/resources/db/migration/V22__add_request_columns_and_active_unique_to_student_transfer_statuses.sql @@ -13,7 +13,7 @@ ALTER TABLE student_transfer_statuses ADD COLUMN reject_reason VARCHAR(255) NULL AFTER proof_image_key; -- 상태는 PENDING / PAID / REJECTED다. 한 번도 요청하지 않은 회원은 행이 없으므로 기본값 UNPAID를 없앤다. --- DEFAULT를 남기면 INSERT가 status를 빠뜨려도 조용히 UNPAID로 들어간다(V10과 같은 이유). 기존 UNPAID 행은 V25가 정리한다. +-- DEFAULT를 남기면 INSERT가 status를 빠뜨려도 조용히 UNPAID로 들어간다(V10과 같은 이유). 기존 UNPAID 행은 V23이 정리한다. ALTER TABLE student_transfer_statuses ALTER COLUMN status DROP DEFAULT; diff --git a/infrastructure/db/src/main/resources/db/migration/V25__convert_unpaid_transfer_statuses_to_rejected.sql b/infrastructure/db/src/main/resources/db/migration/V23__convert_unpaid_transfer_statuses_to_rejected.sql similarity index 100% rename from infrastructure/db/src/main/resources/db/migration/V25__convert_unpaid_transfer_statuses_to_rejected.sql rename to infrastructure/db/src/main/resources/db/migration/V23__convert_unpaid_transfer_statuses_to_rejected.sql