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..a1c53ff7
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeAccountProvider.java
@@ -0,0 +1,43 @@
+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.FeeAccount;
+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 FeeAccount.of(
+ 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 amountPerSemester) {
+ FeeAccount account = FeeAccount.create(bank, accountNo, amountPerSemester);
+ adminConfigValueService.upsertValues(Map.of(
+ FeeConfigKeys.TRANSFER_BANK, account.bank(),
+ FeeConfigKeys.TRANSFER_ACCOUNT_NO, account.accountNo(),
+ FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(account.amountPerSemester())
+ ));
+ return account;
+ }
+}
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..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
@@ -2,18 +2,16 @@
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;
@@ -22,16 +20,15 @@
/**
* 운영진 학생회비 API의 문서 명세. 구현은 {@link AdminFeeController}가 맡는다.
*
- * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
- * 파라미터 바인딩(@{@code ModelAttribute}, @{@code RequestBody} 등), 부서 권한(@{@code RequireDepartment})은
- * 구현체에 둔다.
+ * 계좌와 학기당 회비는 한 화면에서 같이 저장하므로 하나의 엔드포인트({@code /account})로 묶여 있다.
*/
@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 +38,35 @@ ApiResponse> getRequests(
@ParameterObject PageParams pageParams
);
- /** 납부 확인 요청 처리. 승인/반려로 상태를 바꾸고 납부자 명부에 반영한다. */
- @Operation(summary = "납부 확인 요청 처리",
- description = "납부 확인 요청을 승인 또는 반려 처리하고 납부자 명부를 갱신한다.")
+ /** 납부 상태 처리. 이미 처리한 요청도 다시 바꿀 수 있다. */
+ @Operation(summary = "학생회비 납부 상태 수정",
+ description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. "
+ + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 반려 사유(255자 이하)가 필요하고, "
+ + "다른 상태로 바꿀 때는 반려 사유를 보내도 저장하지 않는다. "
+ + "확인필요로 돌려받은 학생은 반려 사유를 보고 학기 인증부터 다시 낼 수 있다. "
+ + "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다. "
+ + "드물게 학생이 새 요청을 내는 순간과 겹치면 늦게 저장되는 쪽이 FEE_REQUEST_ALREADY_REQUESTED로 실패할 수 있다.")
@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 = 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(
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 = "학생회비 송금 금액을 설정한다.")
+ /** 회비 납부 설정 변경. 계좌와 학기당 회비를 함께 받는다. */
+ @Operation(summary = "회비 납부 계좌 변경",
+ description = "은행·계좌번호·학기당 회비를 한 번에 저장한다. 은행은 허용 목록 중 하나여야 하고 "
+ + "계좌번호는 하이픈을 제외한 숫자 10~14자리다. 송금 링크는 저장된 값으로 조회 시점에 만들어진다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
- ApiResponse updateAmount(
- AdminApiUser apiUser,
- FeeAmountUpdateRequest request
- );
+ @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 735ea965..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
@@ -1,26 +1,22 @@
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.FeeAccount;
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 +35,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 +48,42 @@ 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, AdminFeeSearchResponse::from));
}
@Override
- @PatchMapping("/requests/{transferStatusId}")
+ @PatchMapping("/requests/{studentTransferStatusId}")
@RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS)
public ApiResponse updateStatus(
AdminApiUser apiUser,
- @PathVariable Long transferStatusId,
+ @PathVariable Long studentTransferStatusId,
@Valid @RequestBody FeeStatusUpdateRequest request
) {
- TransferStatus status = TransferStatus.from(request.status());
- StudentTransferStatus reviewed = adminFeeReviewUseCase.review(transferStatusId, status);
+ StudentTransferStatus reviewed = adminFeeReviewUseCase.review(
+ studentTransferStatusId, request.status(), request.rejectReason());
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
+ @Valid @RequestBody FeeAccountUpdateRequest request
) {
- adminConfigValueService.upsertValue(FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(request.amount()));
- return ApiResponse.success(FeeAmountUpdateResponse.of(request.amount()));
+ 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/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..af513b52
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAccountUpdateRequest.java
@@ -0,0 +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;
+
+/**
+ * 회비 납부 설정 변경. 계좌와 학기당 회비를 한 화면에서 같이 저장하므로 함께 받는다.
+ */
+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/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..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
@@ -1,9 +1,19 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee.request;
-import jakarta.validation.constraints.NotBlank;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;
+/**
+ * 납부 상태 변경. 반려 사유는 확인필요(REJECTED)일 때만 필수라, 필수 여부와 길이는 도메인이 검증한다.
+ */
public record FeeStatusUpdateRequest(
- @NotBlank(message = "처리 상태를 입력해 주세요.")
- String status
+ @Schema(description = "변경할 납부 상태", example = "REJECTED")
+ @NotNull(message = "변경할 납부 상태를 입력해 주세요.")
+ TransferStatus status,
+
+ @Schema(description = "학생에게 보여줄 반려 사유 (최대 255자). 확인필요일 때 필수",
+ example = "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.")
+ 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 6f6fdab6..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,31 +1,50 @@
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;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
+/**
+ * 납부자 목록의 한 줄. 납부를 요청한 회원만 들어온다 — 한 번도 요청하지 않은 회원은 행이 없다.
+ */
public record AdminFeeSearchResponse(
Long studentTransferStatusId,
Long memberId,
String name,
String studentId,
String department,
- String status,
+ boolean isSignedUp,
+ String feeStatus,
+ Integer remainingSemesters,
+ Long transferAmount,
+ String proofImageUrl,
LocalDateTime requestedAt,
- LocalDateTime reviewedAt
+ LocalDateTime confirmedAt,
+ String rejectReason
) {
- // member가 null이면 탈퇴(소프트 삭제)한 회원의 과거 요청이다 — 이름/학번/학과를 알 수 없으니 표시용 placeholder를 쓴다.
- public static AdminFeeSearchResponse of(StudentTransferStatus status, Member member) {
+ // member가 null이면 탈퇴(소프트 삭제)한 회원의 행이다 — 이름·학번·학과를 알 수 없어 표시용 placeholder를 쓴다.
+ // signedUp은 회원가입(전화번호 + 필수 약관 동의)을 마쳤는지다
+ public static AdminFeeSearchResponse from(MemberFeeStatus memberFeeStatus) {
+ StudentTransferStatus transferStatus = memberFeeStatus.transferStatus();
+ Member member = memberFeeStatus.member();
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()
+ memberFeeStatus.signedUp(),
+ transferStatus.getStatus().name(),
+ transferStatus.getRemainingSemesters(),
+ // 요청 시점에 남긴 금액이다. 운영진이 학기당 회비를 바꿔도 이미 들어온 요청의 금액은 그대로 보인다
+ transferStatus.getAmount(),
+ StorageUrlBuilder.build(transferStatus.getProofImageKey()),
+ transferStatus.getRequestedAt(),
+ transferStatus.getConfirmedAt(),
+ transferStatus.getRejectReason()
);
}
}
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..53a6d5d1
--- /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.welfare.domain.fee.domain.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.amountPerSemester(), account.generateBaseTransferLinkUrl());
+ }
+}
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..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,9 +7,18 @@ public record FeeStatusUpdateResponse(
Long studentTransferStatusId,
Long memberId,
String status,
- LocalDateTime reviewedAt
+ String rejectReason,
+ 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.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 40d49e03..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
@@ -10,6 +10,9 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
+/**
+ * 납부 상태 처리. 상태 변경과 납부자 명부 반영이 어긋나면 안 되므로 한 트랜잭션으로 묶는다.
+ */
@Component
@RequiredArgsConstructor
public class AdminFeeReviewUseCase {
@@ -18,14 +21,13 @@ public class AdminFeeReviewUseCase {
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 rejectReason) {
+ StudentTransferStatus reviewed = studentTransferStatusService.review(transferStatusId, status, rejectReason);
+ 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 transferStatus;
+ return reviewed;
}
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java
index b97ea4c9..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,6 +2,7 @@
import java.util.List;
import java.util.Map;
+import java.util.Set;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
@@ -12,6 +13,17 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
+/**
+ * 납부자 목록. {@code welfare}와 {@code member} 두 도메인을 조합한다.
+ *
+ * 페이징과 상태 필터는 회비 쪽에서 한다. 납부를 요청한 회원만 행이 있으므로 목록도 요청한 회원만 나온다
+ * (한 번도 요청하지 않은 미납부 회원은 관리자 화면에서 다루지 않는다).
+ *
+ * member 쪽은 공개 조회 메서드만 쓴다 — 검색은 {@code searchIdsByKeyword}, 회원 정보는 {@code getMapByIds},
+ * 가입 여부는 {@code findSignedUpIds}. 회원 정보와 가입 여부는 한 페이지의 회원을 한 번에 조회한다.
+ *
+ * 트랜잭션을 걸지 않는다. 쓰기가 없고, 조회 사이가 어긋나도 한 줄의 정보가 잠깐 최신이 아닐 뿐이다.
+ */
@Component
@RequiredArgsConstructor
public class AdminFeeSearchUseCase {
@@ -21,25 +33,24 @@ public class AdminFeeSearchUseCase {
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, 가입 여부는 false로 남는다
+ Map membersById = memberService.getMapByIds(pagedMemberIds);
+ Set signedUpIds = memberService.findSignedUpIds(pagedMemberIds);
- return result.map(transferStatus -> new MemberFeeStatus(transferStatus, memberMap.getOrDefault(transferStatus.getMemberId(), null)));
+ return page.map(transferStatus -> new MemberFeeStatus(
+ transferStatus,
+ membersById.get(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
index 5c86a6b3..47966e42 100644
--- 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
@@ -6,5 +6,5 @@
// AdminFeeSearchUseCase가 조합한 결과. DTO(AdminFeeSearchResponse) 변환은 Controller가 한다
// (PageResult<도메인>까지만 UseCase가 만들고, api DTO 조립은 경계에서 하기 위함).
// member는 탈퇴(소프트 삭제)한 회원의 과거 요청이면 null일 수 있다.
-public record MemberFeeStatus(StudentTransferStatus status, Member member) {
+public record MemberFeeStatus(StudentTransferStatus transferStatus, Member member, boolean signedUp) {
}
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..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
@@ -2,41 +2,47 @@
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.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}가 맡는다.
*
- * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
- * 파라미터 바인딩(@{@code RequestParam}, @{@code RequestBody} 등)은 구현체에 둔다.
+ * 납부는 한 번에 요청한다. 학생은 남은 학기 수로 송금 링크를 받아(조회만, 저장하지 않음) 토스에서 송금한 뒤,
+ * 학점이수현황 캡처와 함께 납부를 요청한다. 이때 처음 저장되고 바로 납부확인중이 된다.
+ * 운영진이 확인필요로 돌려보내면 학생은 학기 인증부터 다시 낼 수 있다.
+ *
+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다.
*/
@Tag(name = "학생회비", description = "학생 앱 학생회비 납부")
public interface AppFeeApi {
- /** 내 학생회비 납부 상태와 송금 링크. 학년·학기를 지정하면 해당 학기의 송금 링크를 내려준다. */
+ /** 내 학생회비 납부 현황. 남은 학기 수를 주면 그 값으로 금액과 송금 링크를 계산한다. */
@Operation(summary = "내 학생회비 상태 조회",
- description = "로그인한 학생의 납부 상태와 함께 지정한 학년·학기의 송금 링크를 조회한다.")
+ description = "로그인한 학생의 납부 상태와 납부 계좌를 조회한다. 아무것도 저장하지 않는다. "
+ + "납부 전(UNPAID)이거나 확인필요(REJECTED)인 학생이 remainingSemesters(남은 학기 수, 1~8)를 보내면 "
+ + "그 값으로 납부 금액과 송금 링크를 계산해 준다. 납부확인중·납부완료면 요청할 때 남긴 금액만 보여주고 "
+ + "송금 링크는 주지 않는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
- @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_TRANSFER_LINK_INVALID"})
- ApiResponse getFeeByMemberId(
- AppApiUser apiUser,
- Integer grade,
- Integer semester
- );
+ @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_REMAINING_SEMESTERS", "FEE_ACCOUNT_NOT_FOUND"})
+ ApiResponse getFeeByMemberId(AppApiUser apiUser, @ParameterObject FeeAmountParams params);
- /** 납부 확인 요청. 송금 후 운영진 확인을 요청하는 상태로 전환한다. */
- @Operation(summary = "학생회비 납부 확인 요청",
- description = "송금을 마친 학생이 운영진의 납부 확인을 요청한다.")
+ /** 학생회비 납부 요청. 학기 인증과 송금 완료를 한 번에 받아 바로 납부확인중이 된다. */
+ @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
- );
+ @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..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
@@ -1,18 +1,19 @@
package kr.ac.kookmin.stream.api.app.welfare.fee;
-import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import jakarta.validation.Valid;
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.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.welfare.domain.fee.domain.GradeSemester;
-import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
-import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService;
+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 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
@@ -20,31 +21,18 @@
@RequiredArgsConstructor
public class AppFeeController implements AppFeeApi {
- private final StudentTransferStatusService studentTransferStatusService;
- private final FeeTransferUrlProvider feeTransferUrlProvider;
+ private final FeeStatusUseCase feeStatusUseCase;
+ 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);
- StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId());
-
- return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getTransferUrl(gradeSemester)));
+ public ApiResponse getFeeByMemberId(AppApiUser apiUser, @Valid @ModelAttribute FeeAmountParams params) {
+ return ApiResponse.success(feeStatusUseCase.getMyFee(apiUser.userId(), params.remainingSemesters()));
}
@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, @Valid @RequestBody FeeSubmitRequest 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/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..feb0d2d4
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java
@@ -0,0 +1,40 @@
+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.FeeAccount;
+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 = 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 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/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/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..d6455c21
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeAmountParams.java
@@ -0,0 +1,10 @@
+package kr.ac.kookmin.stream.api.app.welfare.fee.request;
+
+/**
+ * @param remainingSemesters 남은 학기 수. 주면 그 값으로 납부 금액과 송금 링크를 계산하고, 없으면 계산하지 않는다.
+ * 범위(1~8)는 회비 정책이라 {@code RemainingSemesters}가 검증한다
+ */
+public record FeeAmountParams(
+ Integer remainingSemesters
+) {
+}
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..fe6ae1ab
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java
@@ -0,0 +1,27 @@
+package kr.ac.kookmin.stream.api.app.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.Size;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeSubmitCommand;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.RemainingSemesters;
+
+/**
+ * 학생회비 납부 요청. 송금을 마친 뒤 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 함께 보낸다.
+ */
+public record FeeSubmitRequest(
+ @Schema(description = "남은 학기 수 (1~8)", example = "6")
+ @NotNull(message = "남은 학기 수를 입력해 주세요.")
+ 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 757331df..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
@@ -1,18 +1,53 @@
package kr.ac.kookmin.stream.api.app.welfare.fee.response;
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;
+/**
+ * 학생회비 납부 현황. 화면은 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 rejectReason
) {
- 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, TransferGuide transferGuide) {
+ return new FeeResponse(null, NOT_REQUESTED_STATUS, account.bank(), account.accountNo(),
+ transferGuide.remainingSemesters(), transferGuide.amount(), transferGuide.linkUrl(), null, null, null);
+ }
+
+ /** 요청이 있는 학생. transferGuide는 확인필요(다시 낼 수 있는 상태)일 때만 쓴다. */
+ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account, TransferGuide transferGuide) {
+ TransferStatus status = transferStatus.getStatus();
+
+ // 반려면 다시 낼 수 있으므로 미납부와 같이 다룬다 — 이전 요청 값 대신 이번에 받은 남은 학기 수로 계산한 값을 준다
+ if (transferStatus.isRequestable()) {
+ return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(),
+ transferGuide.remainingSemesters(), transferGuide.amount(), transferGuide.linkUrl(),
+ transferStatus.getRequestedAt(), null, transferStatus.getRejectReason());
+ }
+
+ return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(),
+ transferStatus.getRemainingSemesters(), transferStatus.getAmount(), null,
+ transferStatus.getRequestedAt(), transferStatus.getConfirmedAt(), null);
}
}
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
new file mode 100644
index 00000000..288bdfc4
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
@@ -0,0 +1,58 @@
+package kr.ac.kookmin.stream.api.app.welfare.fee.usecase;
+
+import java.util.Objects;
+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.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.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;
+
+/**
+ * 학생회비 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 한 번에 받는다.
+ * {@code welfare}(납부 요청)와 {@code file}(인증 이미지) 두 도메인을 조합한다.
+ *
+ * 메서드에 {@code @Transactional}을 붙이지 않는다. 쓰기는 {@code StudentTransferStatusService.submit} 하나뿐이고
+ * 그 트랜잭션 안에서 끝난다. 인증 이미지는 조회해서 검증만 한다.
+ *
+ * 다시 내도 이전 인증 이미지를 지우지 않는다. 반려된 이전 요청이 이력으로 남아 그 이미지를 계속 가리킨다.
+ */
+@Component
+@RequiredArgsConstructor
+public class FeeSubmitUseCase {
+
+ private final StudentTransferStatusService studentTransferStatusService;
+ private final FeeAccountProvider feeAccountProvider;
+ private final FileService fileService;
+
+ public FeeResponse submit(Long memberId, FeeSubmitCommand command) {
+ validateProofImage(command.proofImageKey(), memberId);
+
+ // 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다
+ FeeAccount account = feeAccountProvider.getAccount();
+ long amount = account.calculateTransferAmount(command.remainingSemesters());
+
+ StudentTransferStatus saved = studentTransferStatusService.submit(memberId, command, amount);
+ return FeeResponse.of(saved, account, TransferGuide.notCalculated());
+ }
+
+ // 실재하는 키인지, 본인이 올린 것인지, 학점이수현황 캡처로 올린 것인지 본다.
+ // 카테고리까지 보는 이유는 학생이 다른 용도로 올려둔 파일의 키를 넘기는 걸 막기 위해서다.
+ private void validateProofImage(String proofImageKey, Long memberId) {
+ 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/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/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/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..35ca206d
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeAccount.java
@@ -0,0 +1,57 @@
+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.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 TransferGuide createTransferGuide(RemainingSemesters remainingSemesters) {
+ long amount = calculateTransferAmount(remainingSemesters);
+ return TransferGuide.of(remainingSemesters.value(), amount,
+ TossTransferLinkGenerator.generate(bank, accountNo, amount));
+ }
+
+ /** 이 학생이 보낼 금액. 남은 학기 수 × 학기당 회비. */
+ public long calculateTransferAmount(RemainingSemesters remainingSemesters) {
+ return remainingSemesters.calculateAmount(amountPerSemester);
+ }
+
+ /** 금액이 빠진 송금 기본 링크. 계좌가 설정되기 전이면 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/FeeErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java
index e3eb793f..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
@@ -11,11 +11,16 @@
@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_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, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."),
+ 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/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/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/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/StudentTransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java
index cc391cfa..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
@@ -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;
@@ -11,47 +12,99 @@
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class StudentTransferStatus {
+ // reject_reason 컬럼 길이
+ private static final int MAX_REJECT_REASON_LENGTH = 255;
+
private Long id;
private Long memberId;
private TransferStatus status;
+ // 학생이 입력한 남은 학기 수와, 그 근거로 올린 학점이수현황 캡처
+ private Integer remainingSemesters;
+ private String proofImageKey;
+ // 요청 시점에 안내한 납부 금액(남은 학기 수 × 그때의 학기당 회비). 운영진이 학기당 회비를 바꿔도 그대로다
+ private Long amount;
+ // 관리자가 확인필요로 처리하며 학생에게 남긴 반려 사유. REJECTED일 때만 값이 있다
+ private String rejectReason;
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 create(
+ Long memberId,
+ int remainingSemesters,
+ String proofImageKey,
+ long amount
+ ) {
+ return new StudentTransferStatus(null, memberId, TransferStatus.PENDING, remainingSemesters, proofImageKey,
+ amount, null, null, null, null);
}
public static StudentTransferStatus of(
Long id,
Long memberId,
TransferStatus status,
+ Integer remainingSemesters,
+ String proofImageKey,
+ Long amount,
+ String rejectReason,
LocalDateTime reviewedAt,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
- return new StudentTransferStatus(id, memberId, status, reviewedAt, createdAt, updatedAt);
+ return new StudentTransferStatus(id, memberId, status, remainingSemesters, proofImageKey,
+ amount, rejectReason, 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 boolean isRequestable() {
+ return status == TransferStatus.REJECTED;
+ }
+
+ public void validateRequestable() {
+ if (!isRequestable()) {
+ throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED);
}
+ }
- this.status = TransferStatus.PENDING;
- this.reviewedAt = null;
+ // 요청마다 새 행을 만들므로 행이 생긴 시각이 곧 요청 시각이다
+ public LocalDateTime getRequestedAt() {
+ return createdAt;
}
- public void review(TransferStatus reviewedStatus, LocalDateTime now) {
+ /** 납부 확인 시각. 납부완료일 때의 처리 시각이다. */
+ public LocalDateTime getConfirmedAt() {
+ return status == TransferStatus.PAID ? reviewedAt : null;
+ }
+
+ /**
+ * 관리자가 납부 상태를 바꾼다. 이미 처리한 요청도 다시 바꿀 수 있다 — 잘못 처리한 경우를 되돌리기 위해서다.
+ * 그래서 납부완료도 종결 상태가 아니다.
+ *
+ * 반려 사유는 확인필요일 때만 남기고, 확인필요면 반드시 있어야 한다 — 학생이 사유를 보고 다시 내기 때문이다.
+ * 납부확인중으로 되돌리면 아직 처리 전이라는 뜻이므로 처리 시각도 지운다.
+ */
+ public void review(TransferStatus reviewedStatus, String rejectReason, LocalDateTime now) {
+ validateRejectReason(reviewedStatus, rejectReason);
+
this.status = reviewedStatus;
- this.reviewedAt = now;
+ this.rejectReason = reviewedStatus == TransferStatus.REJECTED ? rejectReason.strip() : null;
+ this.reviewedAt = reviewedStatus == TransferStatus.PENDING ? null : now;
+ }
+
+ // 확인필요는 학생에게 보여줄 반려 사유가 있어야 의미가 있다. 그 외 상태에서는 사유를 받아도 버린다
+ private static void validateRejectReason(TransferStatus status, String rejectReason) {
+ if (status != TransferStatus.REJECTED) {
+ return;
+ }
+ 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/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/domain/TransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java
index be08d99c..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,10 +2,13 @@
import kr.ac.kookmin.stream.common.BusinessException;
+// 납부 요청의 상태. 한 번도 요청하지 않은 회원(미납부)은 행이 없으므로 여기에 상태가 없다.
+// 진행 중·완료(PENDING, PAID)는 V24의 active_member_id 식에 그대로 적혀 있어, 상태를 더하거나 바꾸면 그 식도 같이 바꿔야 한다
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..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
@@ -7,11 +7,12 @@
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);
- // 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
deleted file mode 100644
index af2ef180..00000000
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java
+++ /dev/null
@@ -1,18 +0,0 @@
-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);
- return (long) remainingSemesters * amountPerSemester;
- }
-}
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);
// 회원이 납부자 명부에 등록돼 있는지 확인하고, 아니면 예외를 던진다(회비 납부 여부가 필요한 다른 도메인의 조건 확인용).
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..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
@@ -1,19 +1,33 @@
package kr.ac.kookmin.stream.welfare.domain.fee.service;
import java.util.List;
+import java.util.Optional;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.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;
public interface StudentTransferStatusService {
- StudentTransferStatus getByMemberId(Long memberId);
+ /** 회원의 최신 납부 요청 — 곧 회원의 지금 상태다. 한 번도 요청하지 않은 회원(미납부)이면 비어 있다. */
+ Optional findLatestByMemberId(Long memberId);
- StudentTransferStatus requestConfirmation(Long memberId);
+ /**
+ * 학기 인증과 송금 완료를 한 번에 받아 납부확인중인 새 요청을 만든다. 처음이든 반려 뒤든 늘 새 행이고,
+ * 이전 요청은 그대로 남는다. 최신 요청이 납부확인중·납부완료면 {@code FEE_REQUEST_ALREADY_REQUESTED}다.
+ *
+ * @param amount 요청 시점에 안내한 납부 금액
+ */
+ StudentTransferStatus submit(Long memberId, FeeSubmitCommand command, long amount);
// status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음
PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset);
- StudentTransferStatus review(Long transferStatusId, TransferStatus status);
+ /**
+ * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. rejectReason은 확인필요일 때만 남고,
+ * 확인필요인데 비어 있거나 255자를 넘으면 {@code INVALID_REJECT_REASON}다.
+ * 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다.
+ */
+ 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 fb29f8e7..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
@@ -2,10 +2,13 @@
import java.time.LocalDateTime;
import java.util.List;
+import java.util.Optional;
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;
+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;
@@ -21,24 +24,25 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService {
private final StudentTransferStatusRepository studentTransferStatusRepository;
@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
@Transactional
- public StudentTransferStatus requestConfirmation(Long memberId) {
- StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId)
- .map(existing -> {
- existing.requestConfirmation();
- return existing;
- })
- .orElseGet(() -> StudentTransferStatus.create(memberId));
+ public StudentTransferStatus submit(Long memberId, FeeSubmitCommand command, long amount) {
+ // 반려된 요청 뒤에만 새 요청을 받는다. 이전 요청은 고치지 않고 이력으로 남긴다
+ studentTransferStatusRepository.findLatestByMemberId(memberId)
+ .ifPresent(StudentTransferStatus::validateRequestable);
+
+ // 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V24)가 두 번째 저장을 막는다
+ StudentTransferStatus transferStatus = StudentTransferStatus.create(
+ memberId, command.remainingSemesters().value(), command.proofImageKey(), amount);
return studentTransferStatusRepository.save(transferStatus);
}
+ // 여기는 readOnly를 유지한다. 페이지 조회는 목록과 전체 개수를 두 번에 나눠 읽으므로,
+ // 한 트랜잭션으로 묶어야 그 사이 들어온 쓰기 때문에 totalCount와 content가 어긋나지 않는다.
@Override
@Transactional(readOnly = true)
public PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset) {
@@ -47,18 +51,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);
- }
+ validateLatest(transferStatus);
- transferStatus.review(status, LocalDateTime.now());
+ transferStatus.review(status, rejectReason, LocalDateTime.now(DateUtil.KST));
return studentTransferStatusRepository.save(transferStatus);
}
+
+ // 지난 요청은 이력이라 바꾸지 않는다. 회원의 지금 상태는 최신 요청 하나뿐이고, 지난 요청을 되살리면
+ // 회원당 진행 중 요청이 둘이 될 수 있다
+ private void validateLatest(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/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);
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..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
@@ -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) 인덱스다(V23)
+ 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/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()
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..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
@@ -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;
@@ -21,9 +21,11 @@
* 처리 권한자가 여러 명으로 늘어나면 동시 처리 경합을 막기 위해 @Version 도입을 검토한다.
*/
@Entity
+// uk_student_transfer_statuses_active_member_id(V24)는 generated column active_member_id에 걸려 있다.
+// 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", columnList = "member_id")
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentTransferStatusJpaEntity extends BaseTimeEntity {
@@ -40,6 +42,18 @@ public class StudentTransferStatusJpaEntity extends BaseTimeEntity {
@Column(nullable = false, length = 20)
private TransferStatus status;
+ @Column(name = "remaining_semesters")
+ private Integer remainingSemesters;
+
+ @Column(name = "amount")
+ private Long amount;
+
+ @Column(name = "proof_image_key")
+ private String proofImageKey;
+
+ @Column(name = "reject_reason")
+ private String rejectReason;
+
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
@@ -47,6 +61,10 @@ private StudentTransferStatusJpaEntity(StudentTransferStatus request) {
this.id = request.getId();
this.memberId = request.getMemberId();
this.status = request.getStatus();
+ this.remainingSemesters = request.getRemainingSemesters();
+ this.amount = request.getAmount();
+ this.proofImageKey = request.getProofImageKey();
+ this.rejectReason = request.getRejectReason();
this.reviewedAt = request.getReviewedAt();
}
@@ -55,6 +73,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, 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 a6856b84..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
@@ -9,19 +9,26 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+/**
+ * 납부 요청은 이력으로 쌓인다. 회원의 지금 상태는 PK가 가장 큰 행이다 — 같은 회원에게 반려(REJECTED)가 여러 개
+ * 쌓이고 최신 행 자체가 반려일 수도 있어 상태로는 고를 수 없고, created_at은 초 단위라 같은 초에 겹칠 수 있다.
+ */
public interface StudentTransferStatusJpaRepository extends JpaRepository {
- Optional findByMemberId(Long memberId);
+ Optional findFirstByMemberIdOrderByIdDesc(Long memberId);
+ // 회원별 최신 요청만 대상으로 한다. 상태 필터도 최신 요청의 상태로 건다
@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..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
@@ -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,14 @@
@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
@@ -47,6 +54,16 @@ public PageResult search(TransferStatus status, List