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
- * 계좌(은행/계좌번호)와 학기당 금액을 조회 시점에 조합해 링크를 만든다 — 계좌만 바뀌거나 금액만 바뀌어도
- * 항상 최신 값을 반영하기 위함(둘 다 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
+ * 납부 전에서만 가능하다. 확인필요로 처리된 회원은 직접 다시 제출하지 못하고,
+ * 관리자가 납부 전으로 되돌려야 한다(화면의 진입점이 "문의하기" 하나뿐이다).
+ */
+ 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