diff --git a/.gitignore b/.gitignore index da63ff01..958dd7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ docs/plans # Firebase 서비스 계정 키 (env로 주입하고 파일은 커밋하지 않는다) *firebase-adminsdk*.json + +# 로컬 환경변수. .env.example만 저장소에 둔다 +.env +.env.local 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..9316b183 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,48 @@ 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.FeeConfirmResponse; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; -import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse; +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 { - /** 내 학생회비 납부 상태와 송금 링크. 학년·학기를 지정하면 해당 학기의 송금 링크를 내려준다. */ + /** 내 학생회비 납부 현황. 화면은 feeStatus와 studentTransferStatusId로 분기한다. */ @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 = "로그인한 학생의 납부 상태와 납부 계좌, 납부 금액과 송금 링크를 조회한다. " + + "납부 정보를 제출하기 전이거나 확인필요 상태면 금액과 송금 링크는 내려가지 않는다.") + @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_ACCOUNT_NOT_FOUND"}) + ApiResponse getFeeByMemberId(AppApiUser apiUser); + + /** 납부 정보 제출. 상태는 납부 전 그대로이고, 돌려받은 송금 링크로 토스에서 송금한다. */ + @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); - /** 납부 확인 요청. 송금 후 운영진 확인을 요청하는 상태로 전환한다. */ - @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 = {"FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_ALREADY_REQUESTED"}) + ApiResponse requestConfirmation(AppApiUser apiUser); } 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..56c9e2cb 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,10 +1,12 @@ 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.FeeConfirmResponse; 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.api.app.welfare.fee.response.FeeSubmitResponse; +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.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; @@ -12,7 +14,6 @@ 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 @@ -22,29 +23,26 @@ public class AppFeeController implements AppFeeApi { private final StudentTransferStatusService studentTransferStatusService; private final FeeTransferUrlProvider feeTransferUrlProvider; + private final FeeSubmitUseCase feeSubmitUseCase; @Override @GetMapping("/me") - public ApiResponse getFeeByMemberId( - AppApiUser apiUser, - @RequestParam(required = false) Integer grade, - @RequestParam(required = false) Integer semester - ) { - GradeSemester gradeSemester = GradeSemester.of(grade, semester); + public ApiResponse getFeeByMemberId(AppApiUser apiUser) { StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId()); - return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getTransferUrl(gradeSemester))); + return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getAccount())); } @Override @PostMapping - public ApiResponse requestConfirmation( - AppApiUser apiUser, - @RequestBody FeeConfirmRequest request - ) { - GradeSemester gradeSemester = request.toGradeSemester(); - StudentTransferStatus transferStatus = studentTransferStatusService.requestConfirmation(apiUser.userId()); + public ApiResponse submit(AppApiUser apiUser, @RequestBody FeeSubmitRequest request) { + return ApiResponse.success(feeSubmitUseCase.submit(apiUser.userId(), request)); + } - return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getTransferUrl(gradeSemester))); + @Override + @PostMapping("/requests/confirm") + public ApiResponse requestConfirmation(AppApiUser apiUser) { + return ApiResponse.success( + FeeConfirmResponse.from(studentTransferStatusService.requestConfirmation(apiUser.userId()))); } } 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..a2812b66 --- /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 transferAmountFor(int remainingSemesters) { + return FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester); + } + + /** 금액이 붙은 토스 송금 링크. */ + public String transferLinkFor(long transferAmount) { + return TossTransferLinkGenerator.generate(bank, accountNo, transferAmount); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java index 32e2cd2e..ea3493ed 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java @@ -5,18 +5,15 @@ 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). + * 계좌와 학기당 금액을 조회 시점에 함께 읽는다 — 계좌만 바뀌거나 금액만 바뀌어도 항상 최신 값을 + * 반영하기 위함(둘 다 admin_config_values에서 따로 관리, FeeConfigKeys 참고). + * welfare 도메인은 internal 모듈에 의존할 수 없어 이 조회를 api에 둔다. */ @Component @RequiredArgsConstructor @@ -24,21 +21,19 @@ public class FeeTransferUrlProvider { private final AdminConfigValueService adminConfigValueService; - // 계좌가 아직 설정되지 않았으면 링크를 만들 수 없으므로 예외를 던진다. - public String getTransferUrl(GradeSemester gradeSemester) { + /** 계좌가 설정되지 않았으면 송금 자체가 불가능하므로 막는다. */ + 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_TRANSFER_LINK_INVALID); + throw new BusinessException(FeeErrorCode.FEE_ACCOUNT_NOT_FOUND); } 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); + return new FeeAccount(bank, accountNo, amountPerSemester); } } 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..a702d993 --- /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/FeeConfirmResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java new file mode 100644 index 00000000..d8dc0c1e --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.api.app.welfare.fee.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; + +/** 송금 확인 요청 결과. */ +public record FeeConfirmResponse( + Long studentTransferStatusId, + String feeStatus, + LocalDateTime requestedAt +) { + public static FeeConfirmResponse from(StudentTransferStatus transferStatus) { + return new FeeConfirmResponse( + transferStatus.getId(), transferStatus.getStatus().name(), transferStatus.getRequestedAt()); + } +} 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..b7c09924 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,49 @@ 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와 studentTransferStatusId로 분기한다 — + * 납부 전이면서 id가 null이면 남은 학기 입력, id가 있으면 송금 완료 여부 확인 화면이다. + */ 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); + /** + * 금액과 송금 링크는 보낼 금액을 정할 수 있을 때만 내려준다. 제출 전이면 남은 학기 수를 모르고, + * 확인필요면 아직 납부가 확인되지 않아 화면이 납부금액을 "-"로 둔다. 확인필요에서 송금 링크를 + * 다시 주면 "또 보내라"로 읽히는 것도 이유다. 재제출은 관리자가 납부 전으로 되돌린 뒤에 열린다. + */ + public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account) { + Integer remainingSemesters = transferStatus.getRemainingSemesters(); + boolean payable = remainingSemesters != null && transferStatus.getStatus() != TransferStatus.NEEDS_CHECK; + + Long transferAmount = payable ? account.transferAmountFor(remainingSemesters) : null; + + return new FeeResponse( + transferStatus.getId(), + transferStatus.getStatus().name(), + account.bank(), + account.accountNo(), + remainingSemesters, + transferAmount, + transferAmount == null ? null : account.transferLinkFor(transferAmount), + transferStatus.getRequestedAt(), + // 납부 확인 시각은 PAID일 때의 처리 시각이다. 그 외 상태에서는 보여주지 않는다 + transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null, + transferStatus.getStatus() == TransferStatus.NEEDS_CHECK ? transferStatus.getAdminNote() : null + ); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java new file mode 100644 index 00000000..73eca1fb --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.api.app.welfare.fee.response; + +import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount; + +/** 납부 정보 제출 결과. 학생은 이 계좌와 금액으로 토스에서 송금한다. */ +public record FeeSubmitResponse( + String bank, + String accountNo, + Integer remainingSemesters, + Long transferAmount, + String transferLinkUrl +) { + public static FeeSubmitResponse of(int remainingSemesters, FeeAccount account) { + long transferAmount = account.transferAmountFor(remainingSemesters); + + return new FeeSubmitResponse( + account.bank(), account.accountNo(), remainingSemesters, + transferAmount, account.transferLinkFor(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..2a3861de --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java @@ -0,0 +1,100 @@ +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.FeeTransferUrlProvider; +import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; +import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse; +import kr.ac.kookmin.stream.common.BusinessException; +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.StudentTransferStatusService; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 납부 정보 제출. {@code welfare}(납부 상태)와 {@code file}(인증 이미지) 두 도메인을 조합한다. + *

+ * 메서드에 {@code @Transactional}을 붙이지 않는다. 저장은 {@code StudentTransferStatusService.submit}이 + * 자기 트랜잭션에서 끝내고, 이전 이미지 삭제는 R2 호출이라 롤백되지 않아 그 커밋 뒤에 해야 하기 때문이다 + * ({@code architecture.md} 6-1절 "롤백 불가한 외부 부수효과"). + */ +@Component +@RequiredArgsConstructor +public class FeeSubmitUseCase { + + private static final Logger log = LoggerFactory.getLogger(FeeSubmitUseCase.class); + + private static final int MIN_REMAINING_SEMESTERS = 1; + private static final int MAX_REMAINING_SEMESTERS = 8; + + private final StudentTransferStatusService studentTransferStatusService; + private final FeeTransferUrlProvider feeTransferUrlProvider; + private final FileService fileService; + + public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) { + int remainingSemesters = validateRemainingSemesters(request.remainingSemesters()); + validateProofImage(request.proofImageKey(), memberId); + + // 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다 + FeeAccount account = feeTransferUrlProvider.getAccount(); + + // 교체될 이전 키를 알려고 한 번 더 읽는다. submit이 돌려주게 하면 반환 타입에 값을 하나 더 얹어야 해서, + // 유니크 인덱스 조회 한 번을 받아들였다. 같은 학생의 동시 제출이 겹치면 낡은 키를 볼 수 있는데, + // 그때 생기는 건 고아 파일 하나뿐이라 파일 정리 배치가 걷어간다. + String previousProofImageKey = studentTransferStatusService.getByMemberId(memberId).getProofImageKey(); + StudentTransferStatus saved = + studentTransferStatusService.submit(memberId, remainingSemesters, request.proofImageKey()); + + deletePreviousProofImage(previousProofImageKey, saved.getProofImageKey()); + + return FeeSubmitResponse.of(remainingSemesters, account); + } + + private int validateRemainingSemesters(Integer remainingSemesters) { + if (remainingSemesters == null + || remainingSemesters < MIN_REMAINING_SEMESTERS + || remainingSemesters > MAX_REMAINING_SEMESTERS) { + throw new BusinessException(FeeErrorCode.INVALID_REMAINING_SEMESTERS); + } + return remainingSemesters; + } + + // 실재하는 키인지, 본인이 올린 것인지, 학점이수현황 캡처로 올린 것인지 본다. + // 카테고리까지 보는 이유는 학생이 다른 용도로 올려둔 파일의 키를 넘기는 걸 막기 위해서다. + 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); + } + } + + /** + * 재제출로 교체된 이전 이미지를 지운다. 아무도 가리키지 않게 된 데다 학번·이름이 찍혀 있어 남겨둘 이유가 없다. + *

+ * 삭제가 실패해도 제출은 성공으로 둔다 — 고아 파일 하나가 남는 편이 제출 실패보다 낫다. + * 남은 것은 파일 정리 배치가 걷어간다. + */ + private void deletePreviousProofImage(String previousKey, String currentKey) { + if (previousKey == null || previousKey.equals(currentKey)) { + return; + } + try { + fileService.findByFileKey(previousKey).ifPresent(file -> fileService.delete(file.getId())); + } catch (RuntimeException e) { + log.warn("이전 인증 이미지를 지우지 못했습니다. fileKey={}", previousKey, e); + } + } +} diff --git a/core/domain/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/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..d3f0ab5e 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,14 @@ @AllArgsConstructor public enum FeeErrorCode implements ErrorCode { - // 상태 문자열이 UNPAID/PENDING/PAID가 아닌 경우와, 처리 상태로 PENDING을 지정한 경우를 함께 쓴다 + // 상태 문자열이 UNPAID/PENDING/PAID/NEEDS_CHECK가 아닌 경우에 쓴다 INVALID_TRANSFER_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 납부 상태입니다."), - FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 납부 확인 요청입니다."), + INVALID_REMAINING_SEMESTERS(ErrorStatus.BAD_REQUEST, "유효하지 않은 남은 학기 수입니다."), + INVALID_PROOF_IMAGE(ErrorStatus.BAD_REQUEST, "유효하지 않은 인증 이미지입니다."), + FEE_ACCOUNT_NOT_FOUND(ErrorStatus.NOT_FOUND, "학생회비 납부 계좌가 등록되지 않았습니다."), + FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "제출된 납부 정보가 없습니다."), + FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."), FEE_REQUEST_ALREADY_REVIEWED(ErrorStatus.CONFLICT, "이미 처리된 납부 확인 요청입니다."), - FEE_TRANSFER_LINK_INVALID(ErrorStatus.INTERNAL_SERVER_ERROR, "송금 요청 URL이 생성되지 않았습니다. 관리자에게 문의해 주세요."), 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..66a4c858 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,39 +15,68 @@ public class StudentTransferStatus { private Long id; private Long memberId; private TransferStatus status; + // 학생이 입력한 남은 학기 수와, 그 근거로 올린 학점이수현황 캡처. 제출 전에는 둘 다 null이다 + private Integer remainingSemesters; + private String proofImageKey; + // 관리자가 확인필요로 처리하며 남긴 안내 문구. NEEDS_CHECK일 때만 값이 있다 + private String adminNote; + private LocalDateTime requestedAt; private LocalDateTime reviewedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; + // 납부 정보를 처음 제출하는 회원. 상태는 납부 전에서 시작하고, 값은 submit이 채운다 public static StudentTransferStatus create(Long memberId) { - return new StudentTransferStatus(null, memberId, TransferStatus.PENDING, null, null, null); + return new StudentTransferStatus( + null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null); } - // 아직 확인 요청을 한 번도 보내지 않은 회원을 위한 가상의 기본값 (DB에 row가 없음, 저장하지 않는다) + // 아직 아무것도 제출하지 않아 DB에 row가 없는 회원을 위한 가상의 기본값 (저장하지 않는다). + // create와 상태가 같아졌으므로 둘의 차이는 "저장되는가" 하나뿐이다. public static StudentTransferStatus notRequested(Long memberId) { - return new StudentTransferStatus(null, memberId, TransferStatus.UNPAID, null, null, null); + return new StudentTransferStatus( + null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null); } public static StudentTransferStatus of( Long id, Long memberId, TransferStatus status, + Integer remainingSemesters, + String proofImageKey, + 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, + 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; - } + /** + * 납부 정보(남은 학기 수·학점이수현황 캡처)를 제출한다. 상태는 납부 전 그대로다 — + * 송금 확인 요청({@link #requestConfirmation()})을 해야 납부확인중으로 넘어간다. + *

+ * 납부 전에서만 가능하다. 확인필요로 처리된 회원은 직접 다시 제출하지 못하고, + * 관리자가 납부 전으로 되돌려야 한다(화면의 진입점이 "문의하기" 하나뿐이다). + */ + public void submit(int remainingSemesters, String proofImageKey) { + requireUnpaid(); + + this.remainingSemesters = remainingSemesters; + this.proofImageKey = proofImageKey; + } + + /** + * 송금 확인을 요청한다. 이전 처리 흔적(관리자 안내 문구·처리 시각)은 지워 새 요청으로 만든다. + */ + public void requestConfirmation(LocalDateTime now) { + requireUnpaid(); this.status = TransferStatus.PENDING; + this.requestedAt = now; + this.adminNote = null; this.reviewedAt = null; } @@ -54,4 +84,10 @@ public void review(TransferStatus reviewedStatus, LocalDateTime now) { this.status = reviewedStatus; this.reviewedAt = now; } + + private void requireUnpaid() { + if (status != TransferStatus.UNPAID) { + throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED); + } + } } 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..b9244ebb 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 @@ -5,7 +5,8 @@ public enum TransferStatus { UNPAID, PENDING, - PAID; + PAID, + NEEDS_CHECK; // Spring MVC는 잘못된 enum 문자열을 MethodArgumentNotValidException이 아닌 다른 예외로 던지므로 // 컨트롤러가 String으로 받아 이 메서드로 변환한다. 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 index af2ef180..b61d941c 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,12 @@ package kr.ac.kookmin.stream.welfare.domain.fee.service; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.GradeSemester; - -// 4년(8학기)치 회비를 남은 학기 수만큼만 걷는다 — (전체 8학기 - 이수완료학기) * 학기당 금액. -// 이수완료학기는 학년·학기로 계산한다: 1학년 1학기면 0학기 이수, 2학년 1학기면 2학기 이수하는 식. +// 남은 학기 수만큼만 회비를 걷는다 — 남은 학기 수 × 학기당 금액. +// 남은 학기 수는 학생이 직접 입력하고 학점이수현황 캡처로 증빙한다(예전에는 학년·학기로 계산했다). public final class FeeAmountCalculator { - private static final int TOTAL_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); + 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..ab49881a 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java @@ -10,6 +10,13 @@ public interface StudentTransferStatusService { StudentTransferStatus getByMemberId(Long memberId); + /** + * 납부 정보(남은 학기 수·학점이수현황 캡처)를 저장한다. 상태는 납부 전 그대로다. + * 행이 없으면 만들고, 있으면 이전 입력값을 덮어쓴다. + */ + StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey); + + /** 송금 확인을 요청한다. 납부 정보를 제출하지 않았으면 {@code FEE_REQUEST_NOT_FOUND}다. */ StudentTransferStatus requestConfirmation(Long memberId); // status가 null이면 전체 상태, memberIds가 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 fb29f8e7..37a2df49 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 @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.List; 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,8 +21,8 @@ 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)); @@ -29,16 +30,26 @@ public StudentTransferStatus getByMemberId(Long memberId) { @Override @Transactional - public StudentTransferStatus requestConfirmation(Long memberId) { + public StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey) { StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId) - .map(existing -> { - existing.requestConfirmation(); - return existing; - }) .orElseGet(() -> StudentTransferStatus.create(memberId)); + transferStatus.submit(remainingSemesters, proofImageKey); + return studentTransferStatusRepository.save(transferStatus); + } + + // 행이 있어도 남은 학기 수가 없으면 제출 전이다(옛 흐름에서 만들어진 행이 여기 해당한다). + @Override + @Transactional + public StudentTransferStatus requestConfirmation(Long memberId) { + StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId) + .filter(existing -> existing.getRemainingSemesters() != null) + .orElseThrow(() -> new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND)); + transferStatus.requestConfirmation(LocalDateTime.now(DateUtil.KST)); return studentTransferStatusRepository.save(transferStatus); } + // 여기는 readOnly를 유지한다. 페이지 조회는 목록과 전체 개수를 두 번에 나눠 읽으므로, + // 한 트랜잭션으로 묶어야 그 사이 들어온 쓰기 때문에 totalCount와 content가 어긋나지 않는다. @Override @Transactional(readOnly = true) public PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset) { 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(); 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..acfd69ea 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 @@ -40,6 +40,18 @@ 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 = "admin_note", length = 255) + private String adminNote; + + @Column(name = "requested_at") + private LocalDateTime requestedAt; + @Column(name = "reviewed_at") private LocalDateTime reviewedAt; @@ -47,6 +59,10 @@ 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.adminNote = request.getAdminNote(); + this.requestedAt = request.getRequestedAt(); this.reviewedAt = request.getReviewedAt(); } @@ -55,6 +71,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, + adminNote, requestedAt, reviewedAt, getCreatedAt(), getUpdatedAt()); } } 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 new file mode 100644 index 00000000..d289fbc1 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql @@ -0,0 +1,12 @@ +-- 학생이 제출하는 납부 정보(남은 학기 수·학점이수현황 캡처)와, 관리자 안내 문구·송금 확인 요청 시각을 담는다. +-- 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));