From a1478944baf1aa3ef3d294bc94c25a8646d5f400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:00:24 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=ED=95=99=EC=83=9D=ED=9A=8C?= =?UTF-8?q?=EB=B9=84=20=EB=82=A9=EB=B6=80=20=EC=A0=95=EB=B3=B4=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=EA=B3=BC=20=ED=9A=8C=EC=9B=90=20=EB=82=A9=EB=B6=80=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V20__add_fee_submission_columns.sql | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql diff --git a/infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql b/infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql new file mode 100644 index 00000000..11e6f82f --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V20__add_fee_submission_columns.sql @@ -0,0 +1,20 @@ +-- 학생이 제출하는 납부 정보(남은 학기 수·학점이수현황 캡처)와, 관리자 안내 문구·송금 확인 요청 시각을 담는다. +-- status에 NEEDS_CHECK(확인필요)가 추가된다 — UNPAID / PENDING / PAID / NEEDS_CHECK. +-- 기존 행은 제출 전으로 보므로 전부 NULL을 허용한다. +ALTER TABLE student_transfer_statuses + ADD COLUMN remaining_semesters INT NULL, + ADD COLUMN proof_image_key VARCHAR(255) NULL, + ADD COLUMN admin_note VARCHAR(255) NULL, + ADD COLUMN requested_at DATETIME NULL; + +-- 관리자 납부자 목록은 "납부 전" 회원까지 보여줘야 한다. 그런 회원은 student_transfer_statuses에 +-- 행이 없으므로 회원 기준으로 검색·필터·페이징하려면 상태를 회원 쪽에도 둬야 한다. +-- student_transfer_statuses.status와 항상 같은 트랜잭션에서 갱신한다. +ALTER TABLE members + ADD COLUMN fee_status VARCHAR(20) NOT NULL DEFAULT 'UNPAID'; + +-- 실제 조회는 WHERE fee_status = ? AND is_deleted = 0 형태다. 필터 컬럼을 앞에, soft delete를 뒤에 둔다. +CREATE INDEX idx_members_fee_status_is_deleted ON members (fee_status, is_deleted); + +-- proof_image_key로 파일을 찾기 위한 인덱스. files.file_key가 TEXT라 prefix 길이를 지정한다. +CREATE INDEX idx_files_file_key ON files (file_key(255)); From b10daf22c229bcb3092763b7bf8f2ae629abfd49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:03:26 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EB=82=A9=EB=B6=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A0=9C=EC=B6=9C=EA=B3=BC=20=EC=86=A1=EA=B8=88=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 학생이 남은 학기 수와 학점이수현황 캡처를 제출해도 상태는 납부 전에 머물고, 송금 확인 요청을 해야 납부확인중으로 넘어간다. 확인필요로 처리된 회원은 직접 다시 제출하지 못하고 관리자가 납부 전으로 되돌려야 한다. 회원 테이블에도 납부 상태를 둔다. 관리자 납부자 목록이 납부 전 회원까지 보여줘야 하는데 그런 회원은 welfare 쪽에 행이 없어, 회원 기준 조회가 불가능하기 때문이다. --- .../member/domain/member/domain/Member.java | 29 ++++++++-- .../domain/fee/domain/FeeErrorCode.java | 8 ++- .../fee/domain/StudentTransferStatus.java | 58 +++++++++++++++---- .../domain/fee/domain/TransferStatus.java | 3 +- .../service/StudentTransferStatusService.java | 7 +++ .../StudentTransferStatusServiceImpl.java | 19 ++++-- .../stream/db/member/MemberJpaEntity.java | 9 ++- .../StudentTransferStatusJpaEntity.java | 19 +++++- 8 files changed, 127 insertions(+), 25 deletions(-) diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java index 158bc599..0ca0c2c9 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java @@ -13,6 +13,9 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class Member { + // 가입 시점의 기본 납부 상태. welfare의 TransferStatus.UNPAID와 같은 문자열이다 + private static final String UNPAID = "UNPAID"; + private Long id; private String studentId; private String name; @@ -25,10 +28,22 @@ public class Member { private String fcmToken; private Role role; private CouncilDepartment councilDepartment; + /** + * 학생회비 납부 상태(UNPAID / PENDING / PAID / NEEDS_CHECK). + *
+ * 실제 상태는 welfare 모듈의 {@code student_transfer_statuses}가 들고 있고, 여기 있는 값은 그 사본이다. + * 관리자 납부자 목록이 "납부 전" 회원까지 보여줘야 하는데 그런 회원은 welfare 쪽에 행이 없어, + * 회원 기준으로 검색·필터·페이징하려면 상태가 회원 쪽에도 있어야 한다. + *
+ * member 모듈은 이 값을 해석하지 않고 저장·비교만 하므로 welfare의 {@code TransferStatus}를 끌어오지 않고 + * 문자열로 둔다(도메인 모듈끼리는 서로를 모른다). 두 값은 항상 같은 트랜잭션에서 함께 바꾼다. + */ + private String feeStatus; // 로그인으로 처음 가입하는 회원. 운영진 권한·학생회 부서는 가입 뒤 따로 부여한다 public static Member create(String studentId, String name, Department department, String academicStatus) { - return new Member(null, studentId, name, department, academicStatus, null, null, null, Role.STUDENT, null); + return new Member( + null, studentId, name, department, academicStatus, null, null, null, Role.STUDENT, null, UNPAID); } public static Member of( @@ -41,10 +56,11 @@ public static Member of( String phoneNumber, String fcmToken, Role role, - CouncilDepartment councilDepartment + CouncilDepartment councilDepartment, + String feeStatus ) { - return new Member( - id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken, role, councilDepartment); + return new Member(id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken, + role, councilDepartment, feeStatus); } // 로그인할 때마다 provider의 최신 이름·학부·학적 상태로 갱신한다. 바뀐 값이 있으면 true를 돌려준다 @@ -60,6 +76,11 @@ public boolean updateProfile(String name, Department department, String academic return true; } + // 회비 납부 상태를 welfare 쪽 변경에 맞춰 반영한다. 호출부가 두 값을 한 트랜잭션에서 함께 바꾼다 + public void changeFeeStatus(String feeStatus) { + this.feeStatus = feeStatus; + } + public void registerPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } 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..8a704c6f 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,9 +11,13 @@ @AllArgsConstructor public enum FeeErrorCode implements ErrorCode { - // 상태 문자열이 UNPAID/PENDING/PAID가 아닌 경우와, 처리 상태로 PENDING을 지정한 경우를 함께 쓴다 + // 상태 문자열이 UNPAID/PENDING/PAID/NEEDS_CHECK가 아닌 경우에 쓴다 INVALID_TRANSFER_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 납부 상태입니다."), - FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 납부 확인 요청입니다."), + INVALID_REMAINING_SEMESTERS(ErrorStatus.BAD_REQUEST, "유효하지 않은 남은 학기 수입니다."), + INVALID_PROOF_IMAGE(ErrorStatus.BAD_REQUEST, "유효하지 않은 인증 이미지입니다."), + FEE_ACCOUNT_NOT_FOUND(ErrorStatus.NOT_FOUND, "학생회비 납부 계좌가 등록되지 않았습니다."), + FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "제출된 납부 정보가 없습니다."), + FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."), FEE_REQUEST_ALREADY_REVIEWED(ErrorStatus.CONFLICT, "이미 처리된 납부 확인 요청입니다."), FEE_TRANSFER_LINK_INVALID(ErrorStatus.INTERNAL_SERVER_ERROR, "송금 요청 URL이 생성되지 않았습니다. 관리자에게 문의해 주세요."), MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다."); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java index cc391cfa..66a4c858 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.welfare.domain.fee.domain; import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -14,39 +15,68 @@ public class StudentTransferStatus { private Long id; private Long memberId; private TransferStatus status; + // 학생이 입력한 남은 학기 수와, 그 근거로 올린 학점이수현황 캡처. 제출 전에는 둘 다 null이다 + private Integer remainingSemesters; + private String proofImageKey; + // 관리자가 확인필요로 처리하며 남긴 안내 문구. NEEDS_CHECK일 때만 값이 있다 + private String adminNote; + private LocalDateTime requestedAt; private LocalDateTime reviewedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; + // 납부 정보를 처음 제출하는 회원. 상태는 납부 전에서 시작하고, 값은 submit이 채운다 public static StudentTransferStatus create(Long memberId) { - return new StudentTransferStatus(null, memberId, TransferStatus.PENDING, null, null, null); + return new StudentTransferStatus( + null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null); } - // 아직 확인 요청을 한 번도 보내지 않은 회원을 위한 가상의 기본값 (DB에 row가 없음, 저장하지 않는다) + // 아직 아무것도 제출하지 않아 DB에 row가 없는 회원을 위한 가상의 기본값 (저장하지 않는다). + // create와 상태가 같아졌으므로 둘의 차이는 "저장되는가" 하나뿐이다. public static StudentTransferStatus notRequested(Long memberId) { - return new StudentTransferStatus(null, memberId, TransferStatus.UNPAID, null, null, null); + return new StudentTransferStatus( + null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null); } public static StudentTransferStatus of( Long id, Long memberId, TransferStatus status, + Integer remainingSemesters, + String proofImageKey, + String adminNote, + LocalDateTime requestedAt, LocalDateTime reviewedAt, LocalDateTime createdAt, LocalDateTime updatedAt ) { - return new StudentTransferStatus(id, memberId, status, reviewedAt, createdAt, updatedAt); + return new StudentTransferStatus(id, memberId, status, remainingSemesters, proofImageKey, + adminNote, requestedAt, reviewedAt, createdAt, updatedAt); } - // 확인 요청(POST /v1/app/fee) 시각은 별도 컬럼 없이 row의 created_at(최초 생성) 또는 - // 재요청 시점의 updated_at으로 갈음한다 — student_transfer_statuses 테이블에 requested_at 컬럼을 두지 않기로 했다(ERD 기준). - // PAID는 최종 상태다 — 이미 승인된 회원이 다시 요청해도 상태를 되돌리지 않고 그대로 둔다. - public void requestConfirmation() { - if (status == TransferStatus.PAID) { - return; - } + /** + * 납부 정보(남은 학기 수·학점이수현황 캡처)를 제출한다. 상태는 납부 전 그대로다 — + * 송금 확인 요청({@link #requestConfirmation()})을 해야 납부확인중으로 넘어간다. + *
+ * 납부 전에서만 가능하다. 확인필요로 처리된 회원은 직접 다시 제출하지 못하고,
+ * 관리자가 납부 전으로 되돌려야 한다(화면의 진입점이 "문의하기" 하나뿐이다).
+ */
+ public void submit(int remainingSemesters, String proofImageKey) {
+ requireUnpaid();
+
+ this.remainingSemesters = remainingSemesters;
+ this.proofImageKey = proofImageKey;
+ }
+
+ /**
+ * 송금 확인을 요청한다. 이전 처리 흔적(관리자 안내 문구·처리 시각)은 지워 새 요청으로 만든다.
+ */
+ public void requestConfirmation(LocalDateTime now) {
+ requireUnpaid();
this.status = TransferStatus.PENDING;
+ this.requestedAt = now;
+ this.adminNote = null;
this.reviewedAt = null;
}
@@ -54,4 +84,10 @@ public void review(TransferStatus reviewedStatus, LocalDateTime now) {
this.status = reviewedStatus;
this.reviewedAt = now;
}
+
+ private void requireUnpaid() {
+ if (status != TransferStatus.UNPAID) {
+ throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED);
+ }
+ }
}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java
index be08d99c..b9244ebb 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java
@@ -5,7 +5,8 @@
public enum TransferStatus {
UNPAID,
PENDING,
- PAID;
+ PAID,
+ NEEDS_CHECK;
// Spring MVC는 잘못된 enum 문자열을 MethodArgumentNotValidException이 아닌 다른 예외로 던지므로
// 컨트롤러가 String으로 받아 이 메서드로 변환한다. null(상태 필터 미지정)은 그대로 null을 돌려준다.
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java
index 840a678e..ab49881a 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java
@@ -10,6 +10,13 @@ public interface StudentTransferStatusService {
StudentTransferStatus getByMemberId(Long memberId);
+ /**
+ * 납부 정보(남은 학기 수·학점이수현황 캡처)를 저장한다. 상태는 납부 전 그대로다.
+ * 행이 없으면 만들고, 있으면 이전 입력값을 덮어쓴다.
+ */
+ StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey);
+
+ /** 송금 확인을 요청한다. 납부 정보를 제출하지 않았으면 {@code FEE_REQUEST_NOT_FOUND}다. */
StudentTransferStatus requestConfirmation(Long memberId);
// status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java
index fb29f8e7..e7fbacbe 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
@@ -1,6 +1,7 @@
package kr.ac.kookmin.stream.welfare.domain.fee.service.impl;
import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.common.DateUtil;
import java.util.List;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.common.PageOffset;
@@ -29,13 +30,21 @@ public StudentTransferStatus getByMemberId(Long memberId) {
@Override
@Transactional
- public StudentTransferStatus requestConfirmation(Long memberId) {
+ public StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey) {
StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId)
- .map(existing -> {
- existing.requestConfirmation();
- return existing;
- })
.orElseGet(() -> StudentTransferStatus.create(memberId));
+ transferStatus.submit(remainingSemesters, proofImageKey);
+ return studentTransferStatusRepository.save(transferStatus);
+ }
+
+ // 행이 있어도 남은 학기 수가 없으면 제출 전이다(옛 흐름에서 만들어진 행이 여기 해당한다).
+ @Override
+ @Transactional
+ public StudentTransferStatus requestConfirmation(Long memberId) {
+ StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId)
+ .filter(existing -> existing.getRemainingSemesters() != null)
+ .orElseThrow(() -> new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND));
+ transferStatus.requestConfirmation(LocalDateTime.now(DateUtil.KST));
return studentTransferStatusRepository.save(transferStatus);
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java
index 8a084ba7..27e6b014 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java
@@ -60,6 +60,11 @@ public class MemberJpaEntity extends BaseSoftDeleteEntity {
@Column(name = "council_department", length = 30)
private CouncilDepartment councilDepartment;
+ // welfare의 student_transfer_statuses.status 사본. 관리자 납부자 목록이 회원 기준으로
+ // 검색·필터·페이징하려면 여기에도 있어야 한다(자세한 이유는 Member.feeStatus 주석)
+ @Column(name = "fee_status", nullable = false, length = 20)
+ private String feeStatus;
+
private MemberJpaEntity(Member member) {
this.id = member.getId();
this.studentId = member.getStudentId();
@@ -71,6 +76,7 @@ private MemberJpaEntity(Member member) {
this.fcmToken = member.getFcmToken();
this.role = member.getRole();
this.councilDepartment = member.getCouncilDepartment();
+ this.feeStatus = member.getFeeStatus();
}
public static MemberJpaEntity from(Member member) {
@@ -79,6 +85,7 @@ public static MemberJpaEntity from(Member member) {
public Member toDomain() {
return Member.of(
- id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken, role, councilDepartment);
+ id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken,
+ role, councilDepartment, feeStatus);
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java
index cc6c77be..acfd69ea 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java
@@ -40,6 +40,18 @@ public class StudentTransferStatusJpaEntity extends BaseTimeEntity {
@Column(nullable = false, length = 20)
private TransferStatus status;
+ @Column(name = "remaining_semesters")
+ private Integer remainingSemesters;
+
+ @Column(name = "proof_image_key", length = 255)
+ private String proofImageKey;
+
+ @Column(name = "admin_note", length = 255)
+ private String adminNote;
+
+ @Column(name = "requested_at")
+ private LocalDateTime requestedAt;
+
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
@@ -47,6 +59,10 @@ private StudentTransferStatusJpaEntity(StudentTransferStatus request) {
this.id = request.getId();
this.memberId = request.getMemberId();
this.status = request.getStatus();
+ this.remainingSemesters = request.getRemainingSemesters();
+ this.proofImageKey = request.getProofImageKey();
+ this.adminNote = request.getAdminNote();
+ this.requestedAt = request.getRequestedAt();
this.reviewedAt = request.getReviewedAt();
}
@@ -55,6 +71,7 @@ public static StudentTransferStatusJpaEntity from(StudentTransferStatus request)
}
public StudentTransferStatus toDomain() {
- return StudentTransferStatus.of(id, memberId, status, reviewedAt, getCreatedAt(), getUpdatedAt());
+ return StudentTransferStatus.of(id, memberId, status, remainingSemesters, proofImageKey,
+ adminNote, requestedAt, reviewedAt, getCreatedAt(), getUpdatedAt());
}
}
From af0a199de61d0718c46139252e3895849db20e5f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?=
<104981505+xeoxxn@users.noreply.github.com>
Date: Wed, 14 Oct 2026 22:04:05 +0900
Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=95=99=EC=A0=90=EC=9D=B4?=
=?UTF-8?q?=EC=88=98=ED=98=84=ED=99=A9=20=EC=BA=A1=EC=B2=98=20=ED=8C=8C?=
=?UTF-8?q?=EC=9D=BC=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EC=99=80=20?=
=?UTF-8?q?=ED=82=A4=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/kr/ac/kookmin/stream/file/domain/FileCategory.java | 4 +++-
.../ac/kookmin/stream/file/repository/FileRepository.java | 1 +
.../java/kr/ac/kookmin/stream/file/service/FileService.java | 3 +++
.../kookmin/stream/file/service/impl/FileServiceImpl.java | 5 +++++
.../kookmin/stream/file/service/impl/FileUploadPolicy.java | 6 ++++--
.../kr/ac/kookmin/stream/db/file/FileJpaRepository.java | 4 ++++
.../kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java | 5 +++++
7 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java
index 2b11e125..caf0734c 100644
--- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java
+++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java
@@ -6,5 +6,7 @@ public enum FileCategory {
NOTICE_IMAGE,
EVENT_IMAGE,
ARCHIVE_IMAGE,
- LOCKER_SECTION_PHOTO
+ LOCKER_SECTION_PHOTO,
+ // 학생회비 납부 때 남은 학기 수의 근거로 올리는 K스마트멘토 학점이수현황 캡처
+ FEE_TRANSCRIPT_IMAGE
}
diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java
index f34418bc..1a00be2b 100644
--- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java
+++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java
@@ -6,6 +6,7 @@
public interface FileRepository {
Optional
- * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
- * 파라미터 바인딩(@{@code RequestParam}, @{@code RequestBody} 등)은 구현체에 둔다.
+ * 납부는 두 단계다. 납부 정보를 제출해도 상태는 납부 전에 머물고, 토스로 송금한 뒤
+ * 송금 확인을 요청해야 납부확인중이 된다. 제출만 하고 이탈한 학생을 운영진이
+ * 처리 대기로 보지 않기 위해서다.
+ *
+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다.
*/
@Tag(name = "학생회비", description = "학생 앱 학생회비 납부")
public interface AppFeeApi {
- /** 내 학생회비 납부 상태와 송금 링크. 학년·학기를 지정하면 해당 학기의 송금 링크를 내려준다. */
+ /** 내 학생회비 납부 현황. 화면은 feeStatus와 studentTransferStatusId로 분기한다. */
@Operation(summary = "내 학생회비 상태 조회",
- description = "로그인한 학생의 납부 상태와 함께 지정한 학년·학기의 송금 링크를 조회한다.")
- @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
- @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_TRANSFER_LINK_INVALID"})
- ApiResponse
+ * 확인필요는 아직 납부가 확인되지 않은 상태라 화면이 납부금액을 "-"로 두고, 송금 링크를 다시 주면
+ * "또 보내라"로 읽히므로 함께 뺀다. 재제출은 관리자가 납부 전으로 되돌린 뒤에 열린다.
+ */
+ private Integer amountBaseOf(StudentTransferStatus transferStatus) {
+ return transferStatus.getStatus() == TransferStatus.NEEDS_CHECK
+ ? null
+ : transferStatus.getRemainingSemesters();
}
}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java
new file mode 100644
index 00000000..9b171be8
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java
@@ -0,0 +1,10 @@
+package kr.ac.kookmin.stream.api.app.welfare.fee;
+
+/**
+ * 회비 납부 계좌와, 그 계좌로 보낼 금액·송금 링크. 계좌는 전역 하나이고 금액만 학생마다 다르다.
+ *
+ * @param transferAmount 남은 학기 수 × 학기당 금액. 남은 학기 수를 모르면 null
+ * @param transferLinkUrl 금액이 붙은 토스 송금 링크. 금액을 모르면 null
+ */
+public record FeeAccount(String bank, String accountNo, Long transferAmount, String transferLinkUrl) {
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java
index 32e2cd2e..64be2148 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java
@@ -5,18 +5,17 @@
import kr.ac.kookmin.stream.welfare.WelfareConstants;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;
-import kr.ac.kookmin.stream.welfare.domain.fee.domain.GradeSemester;
import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator;
import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
- * 회비 송금 링크 생성을 담당하는 파사드.
+ * 회비 납부 계좌 조회와 송금 링크 생성을 담당하는 파사드.
*
- * 계좌(은행/계좌번호)와 학기당 금액을 조회 시점에 조합해 링크를 만든다 — 계좌만 바뀌거나 금액만 바뀌어도
- * 항상 최신 값을 반영하기 위함(둘 다 admin_config_values에서 따로 관리, FeeConfigKeys 참고).
- * 실제 송금액은 남은 학기 수만큼만 걷는다(FeeAmountCalculator).
+ * 계좌(은행/계좌번호)와 학기당 금액을 조회 시점에 조합한다 — 계좌만 바뀌거나 금액만 바뀌어도 항상 최신 값을
+ * 반영하기 위함(둘 다 admin_config_values에서 따로 관리, FeeConfigKeys 참고).
+ * welfare 도메인은 internal 모듈에 의존할 수 없어 이 조합을 api에 둔다.
*/
@Component
@RequiredArgsConstructor
@@ -24,21 +23,28 @@ public class FeeTransferUrlProvider {
private final AdminConfigValueService adminConfigValueService;
- // 계좌가 아직 설정되지 않았으면 링크를 만들 수 없으므로 예외를 던진다.
- public String getTransferUrl(GradeSemester gradeSemester) {
+ /**
+ * @param remainingSemesters 남은 학기 수. null이면 금액과 링크를 만들지 않는다
+ * (아직 제출 전이거나, 확인필요라 금액을 보여주지 않는 경우)
+ */
+ public FeeAccount getAccount(Integer remainingSemesters) {
String bank = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null);
String accountNo = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null);
if (bank == null || accountNo == null) {
- throw new BusinessException(FeeErrorCode.FEE_TRANSFER_LINK_INVALID);
+ throw new BusinessException(FeeErrorCode.FEE_ACCOUNT_NOT_FOUND);
+ }
+
+ if (remainingSemesters == null) {
+ return new FeeAccount(bank, accountNo, null, null);
}
long amountPerSemester = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT)
.map(Long::parseLong)
.orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT);
+ long transferAmount = FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester);
- long amount = FeeAmountCalculator.calculate(gradeSemester, amountPerSemester);
-
- return TossTransferLinkGenerator.generate(bank, accountNo, amount);
+ return new FeeAccount(bank, accountNo, transferAmount,
+ TossTransferLinkGenerator.generate(bank, accountNo, transferAmount));
}
}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeConfirmRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeConfirmRequest.java
deleted file mode 100644
index 52b82f3e..00000000
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeConfirmRequest.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package kr.ac.kookmin.stream.api.app.welfare.fee.request;
-
-import kr.ac.kookmin.stream.welfare.domain.fee.domain.GradeSemester;
-
-// 학년·학기 검증은 GradeSemester.of에서 하므로, 여기서는 Bean Validation을 붙이지 않는다.
-public record FeeConfirmRequest(Integer grade, Integer semester) {
-
- public GradeSemester toGradeSemester() {
- return GradeSemester.of(grade, semester);
- }
-}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java
new file mode 100644
index 00000000..a702d993
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java
@@ -0,0 +1,18 @@
+package kr.ac.kookmin.stream.api.app.welfare.fee.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * 납부 정보 제출. 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 받는다.
+ *
+ * 검증에 Bean Validation을 쓰지 않는다 — 명세가 항목마다 전용 에러 코드를 요구하는데
+ * 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(coding-style 검증 규칙, fee-domain-study 08번 문서).
+ */
+public record FeeSubmitRequest(
+ @Schema(description = "남은 학기 수 (1~8)", example = "6")
+ Integer remainingSemesters,
+
+ @Schema(description = "업로드 URL 발급 때 받은 이미지 키", example = "files/3f2a9c1e-7b4d-4e8a-9c2f-1a2b3c4d5e6f.png")
+ String proofImageKey
+) {
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java
new file mode 100644
index 00000000..d8dc0c1e
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java
@@ -0,0 +1,16 @@
+package kr.ac.kookmin.stream.api.app.welfare.fee.response;
+
+import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
+
+/** 송금 확인 요청 결과. */
+public record FeeConfirmResponse(
+ Long studentTransferStatusId,
+ String feeStatus,
+ LocalDateTime requestedAt
+) {
+ public static FeeConfirmResponse from(StudentTransferStatus transferStatus) {
+ return new FeeConfirmResponse(
+ transferStatus.getId(), transferStatus.getStatus().name(), transferStatus.getRequestedAt());
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java
index 757331df..fa9059a5 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,42 @@
package kr.ac.kookmin.stream.api.app.welfare.fee.response;
import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;
+/**
+ * 학생회비 납부 현황. 화면은 feeStatus와 studentTransferStatusId로 분기한다 —
+ * 납부 전이면서 id가 null이면 남은 학기 입력, id가 있으면 송금 완료 여부 확인 화면이다.
+ */
public record FeeResponse(
+ Long studentTransferStatusId,
String feeStatus,
+ String bank,
+ String accountNo,
+ Integer remainingSemesters,
+ Long transferAmount,
String transferLinkUrl,
LocalDateTime requestedAt,
- LocalDateTime confirmedAt
+ LocalDateTime confirmedAt,
+ String adminNote
) {
- public static FeeResponse of(StudentTransferStatus request, String transferLinkUrl) {
- LocalDateTime confirmedAt = request.getStatus() == TransferStatus.PAID ? request.getReviewedAt() : null;
- // student_transfer_statuses엔 별도 requested_at 컬럼이 없어, 확인 요청 시각은 row의 created_at으로 갈음한다.
- return new FeeResponse(request.getStatus().name(), transferLinkUrl, request.getCreatedAt(), confirmedAt);
+ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account) {
+ boolean paid = transferStatus.getStatus() == TransferStatus.PAID;
+ boolean needsCheck = transferStatus.getStatus() == TransferStatus.NEEDS_CHECK;
+
+ return new FeeResponse(
+ transferStatus.getId(),
+ transferStatus.getStatus().name(),
+ account.bank(),
+ account.accountNo(),
+ transferStatus.getRemainingSemesters(),
+ account.transferAmount(),
+ account.transferLinkUrl(),
+ transferStatus.getRequestedAt(),
+ // 납부 확인 시각은 PAID일 때의 처리 시각이다. 그 외 상태에서는 보여주지 않는다
+ paid ? transferStatus.getReviewedAt() : null,
+ needsCheck ? transferStatus.getAdminNote() : null
+ );
}
}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
new file mode 100644
index 00000000..b51ccb50
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
@@ -0,0 +1,18 @@
+package kr.ac.kookmin.stream.api.app.welfare.fee.response;
+
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
+
+/** 납부 정보 제출 결과. 학생은 이 계좌와 금액으로 토스에서 송금한다. */
+public record FeeSubmitResponse(
+ String bank,
+ String accountNo,
+ Integer remainingSemesters,
+ Long transferAmount,
+ String transferLinkUrl
+) {
+ public static FeeSubmitResponse of(int remainingSemesters, FeeAccount account) {
+ return new FeeSubmitResponse(
+ account.bank(), account.accountNo(), remainingSemesters,
+ account.transferAmount(), account.transferLinkUrl());
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeConfirmUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeConfirmUseCase.java
new file mode 100644
index 00000000..0f6f284a
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeConfirmUseCase.java
@@ -0,0 +1,29 @@
+package kr.ac.kookmin.stream.api.app.welfare.fee.usecase;
+
+import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeConfirmResponse;
+import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
+import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 송금 확인 요청. {@code welfare}의 납부 상태와 {@code member}의 사본을 함께 바꾼다.
+ * 두 값이 어긋나면 관리자 목록과 상세가 다르게 보이므로 한 트랜잭션으로 묶는다.
+ */
+@Component
+@RequiredArgsConstructor
+public class FeeConfirmUseCase {
+
+ private final StudentTransferStatusService studentTransferStatusService;
+ private final MemberService memberService;
+
+ @Transactional
+ public FeeConfirmResponse confirm(Long memberId) {
+ StudentTransferStatus requested = studentTransferStatusService.requestConfirmation(memberId);
+ memberService.changeFeeStatus(memberId, requested.getStatus().name());
+
+ return FeeConfirmResponse.from(requested);
+ }
+}
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..1c7bdea7
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
@@ -0,0 +1,97 @@
+package kr.ac.kookmin.stream.api.app.welfare.fee.usecase;
+
+import java.util.Objects;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferUrlProvider;
+import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest;
+import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.file.domain.File;
+import kr.ac.kookmin.stream.file.domain.FileCategory;
+import kr.ac.kookmin.stream.file.service.FileService;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
+import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * 납부 정보 제출. {@code welfare}(납부 상태)와 {@code file}(인증 이미지) 두 도메인을 조합한다.
+ *
+ * 메서드에 {@code @Transactional}을 붙이지 않는다. 저장은 {@code StudentTransferStatusService.submit}이
+ * 자기 트랜잭션에서 끝내고, 이전 이미지 삭제는 R2 호출이라 롤백되지 않아 그 커밋 뒤에 해야 하기 때문이다
+ * ({@code architecture.md} 6-1절 "롤백 불가한 외부 부수효과").
+ */
+@Component
+@RequiredArgsConstructor
+public class FeeSubmitUseCase {
+
+ private static final Logger log = LoggerFactory.getLogger(FeeSubmitUseCase.class);
+
+ private static final int MIN_REMAINING_SEMESTERS = 1;
+ private static final int MAX_REMAINING_SEMESTERS = 8;
+
+ private final StudentTransferStatusService studentTransferStatusService;
+ private final FeeTransferUrlProvider feeTransferUrlProvider;
+ private final FileService fileService;
+
+ public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) {
+ int remainingSemesters = validateRemainingSemesters(request.remainingSemesters());
+ validateProofImage(request.proofImageKey(), memberId);
+
+ // 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다
+ FeeAccount account = feeTransferUrlProvider.getAccount(remainingSemesters);
+
+ String previousProofImageKey = studentTransferStatusService.getByMemberId(memberId).getProofImageKey();
+ StudentTransferStatus saved =
+ studentTransferStatusService.submit(memberId, remainingSemesters, request.proofImageKey());
+
+ deletePreviousProofImage(previousProofImageKey, saved.getProofImageKey());
+
+ return FeeSubmitResponse.of(remainingSemesters, account);
+ }
+
+ private int validateRemainingSemesters(Integer remainingSemesters) {
+ if (remainingSemesters == null
+ || remainingSemesters < MIN_REMAINING_SEMESTERS
+ || remainingSemesters > MAX_REMAINING_SEMESTERS) {
+ throw new BusinessException(FeeErrorCode.INVALID_REMAINING_SEMESTERS);
+ }
+ return remainingSemesters;
+ }
+
+ // 실재하는 키인지, 본인이 올린 것인지, 학점이수현황 캡처로 올린 것인지 본다.
+ // 카테고리까지 보는 이유는 학생이 다른 용도로 올려둔 파일의 키를 넘기는 걸 막기 위해서다.
+ private void validateProofImage(String proofImageKey, Long memberId) {
+ if (proofImageKey == null || proofImageKey.isBlank()) {
+ throw new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE);
+ }
+
+ File proofImage = fileService.findByFileKey(proofImageKey)
+ .orElseThrow(() -> new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE));
+
+ if (!Objects.equals(proofImage.getUploaderId(), memberId)
+ || proofImage.getCategory() != FileCategory.FEE_TRANSCRIPT_IMAGE) {
+ throw new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE);
+ }
+ }
+
+ /**
+ * 재제출로 교체된 이전 이미지를 지운다. 아무도 가리키지 않게 된 데다 학번·이름이 찍혀 있어 남겨둘 이유가 없다.
+ *
+ * 삭제가 실패해도 제출은 성공으로 둔다 — 고아 파일 하나가 남는 편이 제출 실패보다 낫다.
+ * 남은 것은 파일 정리 배치가 걷어간다.
+ */
+ private void deletePreviousProofImage(String previousKey, String currentKey) {
+ if (previousKey == null || previousKey.equals(currentKey)) {
+ return;
+ }
+ try {
+ fileService.findByFileKey(previousKey).ifPresent(file -> fileService.delete(file.getId()));
+ } catch (RuntimeException e) {
+ log.warn("이전 인증 이미지를 지우지 못했습니다. fileKey={}", previousKey, e);
+ }
+ }
+}
diff --git a/core/domain/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 39d1a0fd..5defbcdb 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
@@ -7,6 +7,12 @@
import kr.ac.kookmin.stream.member.domain.member.domain.MemberSignUpCommand;
public interface MemberService {
+
+ /**
+ * 회비 납부 상태를 반영한다. 실제 상태는 welfare가 들고 있고 여기 값은 사본이므로,
+ * 호출부(UseCase)가 두 값을 한 트랜잭션에서 함께 바꾼다.
+ */
+ Member changeFeeStatus(Long memberId, String feeStatus);
Member getById(Long id);
List
- * 확인필요는 아직 납부가 확인되지 않은 상태라 화면이 납부금액을 "-"로 두고, 송금 링크를 다시 주면
- * "또 보내라"로 읽히므로 함께 뺀다. 재제출은 관리자가 납부 전으로 되돌린 뒤에 열린다.
- */
- private Integer amountBaseOf(StudentTransferStatus transferStatus) {
- return transferStatus.getStatus() == TransferStatus.NEEDS_CHECK
- ? null
- : transferStatus.getRemainingSemesters();
- }
}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java
deleted file mode 100644
index 9b171be8..00000000
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package kr.ac.kookmin.stream.api.app.welfare.fee;
-
-/**
- * 회비 납부 계좌와, 그 계좌로 보낼 금액·송금 링크. 계좌는 전역 하나이고 금액만 학생마다 다르다.
- *
- * @param transferAmount 남은 학기 수 × 학기당 금액. 남은 학기 수를 모르면 null
- * @param transferLinkUrl 금액이 붙은 토스 송금 링크. 금액을 모르면 null
- */
-public record FeeAccount(String bank, String accountNo, Long transferAmount, String transferLinkUrl) {
-}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferInfo.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferInfo.java
new file mode 100644
index 00000000..06bb4d42
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferInfo.java
@@ -0,0 +1,14 @@
+package kr.ac.kookmin.stream.api.app.welfare.fee;
+
+/**
+ * 회비 송금에 필요한 정보. 계좌는 전역 하나이고 금액만 학생마다 다르다.
+ *
+ * @param transferAmount 남은 학기 수 × 학기당 금액. 금액을 정할 수 없으면 null
+ * @param transferLinkUrl 금액이 붙은 토스 송금 링크. 금액을 모르면 null
+ */
+public record FeeTransferInfo(String bank, String accountNo, Long transferAmount, String transferLinkUrl) {
+
+ public static FeeTransferInfo accountOnly(String bank, String accountNo) {
+ return new FeeTransferInfo(bank, accountNo, null, null);
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java
index 64be2148..538e5801 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java
@@ -27,7 +27,7 @@ public class FeeTransferUrlProvider {
* @param remainingSemesters 남은 학기 수. null이면 금액과 링크를 만들지 않는다
* (아직 제출 전이거나, 확인필요라 금액을 보여주지 않는 경우)
*/
- public FeeAccount getAccount(Integer remainingSemesters) {
+ public FeeTransferInfo getTransferInfo(Integer remainingSemesters) {
String bank = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null);
String accountNo = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null);
@@ -36,7 +36,7 @@ public FeeAccount getAccount(Integer remainingSemesters) {
}
if (remainingSemesters == null) {
- return new FeeAccount(bank, accountNo, null, null);
+ return FeeTransferInfo.accountOnly(bank, accountNo);
}
long amountPerSemester = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT)
@@ -44,7 +44,7 @@ public FeeAccount getAccount(Integer remainingSemesters) {
.orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT);
long transferAmount = FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester);
- return new FeeAccount(bank, accountNo, transferAmount,
+ return new FeeTransferInfo(bank, accountNo, transferAmount,
TossTransferLinkGenerator.generate(bank, accountNo, transferAmount));
}
}
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 fa9059a5..fb5e61a1 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java
@@ -1,7 +1,7 @@
package kr.ac.kookmin.stream.api.app.welfare.fee.response;
import java.time.LocalDateTime;
-import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferInfo;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;
@@ -21,18 +21,23 @@ public record FeeResponse(
LocalDateTime confirmedAt,
String adminNote
) {
- public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account) {
+ /**
+ * 확인필요에서는 금액과 송금 링크를 내려주지 않는다. 아직 납부가 확인되지 않아 화면이 납부금액을
+ * "-"로 두고, 송금 링크를 다시 주면 "또 보내라"로 읽히기 때문이다.
+ * 재제출은 관리자가 납부 전으로 되돌린 뒤에 열린다.
+ */
+ public static FeeResponse of(StudentTransferStatus transferStatus, FeeTransferInfo transferInfo) {
boolean paid = transferStatus.getStatus() == TransferStatus.PAID;
boolean needsCheck = transferStatus.getStatus() == TransferStatus.NEEDS_CHECK;
return new FeeResponse(
transferStatus.getId(),
transferStatus.getStatus().name(),
- account.bank(),
- account.accountNo(),
+ transferInfo.bank(),
+ transferInfo.accountNo(),
transferStatus.getRemainingSemesters(),
- account.transferAmount(),
- account.transferLinkUrl(),
+ needsCheck ? null : transferInfo.transferAmount(),
+ needsCheck ? null : transferInfo.transferLinkUrl(),
transferStatus.getRequestedAt(),
// 납부 확인 시각은 PAID일 때의 처리 시각이다. 그 외 상태에서는 보여주지 않는다
paid ? transferStatus.getReviewedAt() : null,
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
index b51ccb50..6677e3c2 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
@@ -1,6 +1,6 @@
package kr.ac.kookmin.stream.api.app.welfare.fee.response;
-import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferInfo;
/** 납부 정보 제출 결과. 학생은 이 계좌와 금액으로 토스에서 송금한다. */
public record FeeSubmitResponse(
@@ -10,9 +10,9 @@ public record FeeSubmitResponse(
Long transferAmount,
String transferLinkUrl
) {
- public static FeeSubmitResponse of(int remainingSemesters, FeeAccount account) {
+ public static FeeSubmitResponse of(int remainingSemesters, FeeTransferInfo transferInfo) {
return new FeeSubmitResponse(
- account.bank(), account.accountNo(), remainingSemesters,
- account.transferAmount(), account.transferLinkUrl());
+ transferInfo.bank(), transferInfo.accountNo(), remainingSemesters,
+ transferInfo.transferAmount(), transferInfo.transferLinkUrl());
}
}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
index 1c7bdea7..69c58a8f 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
@@ -1,7 +1,7 @@
package kr.ac.kookmin.stream.api.app.welfare.fee.usecase;
import java.util.Objects;
-import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferInfo;
import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferUrlProvider;
import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest;
import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse;
@@ -42,15 +42,18 @@ public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) {
validateProofImage(request.proofImageKey(), memberId);
// 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다
- FeeAccount account = feeTransferUrlProvider.getAccount(remainingSemesters);
+ FeeTransferInfo transferInfo = feeTransferUrlProvider.getTransferInfo(remainingSemesters);
+ // 교체될 이전 키를 알려고 한 번 더 읽는다. submit이 돌려주게 하면 반환 타입에 값을 하나 더 얹어야 해서,
+ // 유니크 인덱스 조회 한 번을 받아들였다. 같은 학생의 동시 제출이 겹치면 낡은 키를 볼 수 있는데,
+ // 그때 생기는 건 고아 파일 하나뿐이라 파일 정리 배치가 걷어간다.
String previousProofImageKey = studentTransferStatusService.getByMemberId(memberId).getProofImageKey();
StudentTransferStatus saved =
studentTransferStatusService.submit(memberId, remainingSemesters, request.proofImageKey());
deletePreviousProofImage(previousProofImageKey, saved.getProofImageKey());
- return FeeSubmitResponse.of(remainingSemesters, account);
+ return FeeSubmitResponse.of(remainingSemesters, transferInfo);
}
private int validateRemainingSemesters(Integer remainingSemesters) {
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 e7fbacbe..37a2df49 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java
@@ -1,9 +1,9 @@
package kr.ac.kookmin.stream.welfare.domain.fee.service.impl;
import java.time.LocalDateTime;
-import kr.ac.kookmin.stream.common.DateUtil;
import java.util.List;
import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.DateUtil;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;
@@ -21,8 +21,8 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService {
private final StudentTransferStatusRepository studentTransferStatusRepository;
+ // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다).
@Override
- @Transactional(readOnly = true)
public StudentTransferStatus getByMemberId(Long memberId) {
return studentTransferStatusRepository.findByMemberId(memberId)
.orElseGet(() -> StudentTransferStatus.notRequested(memberId));
@@ -48,6 +48,8 @@ public StudentTransferStatus requestConfirmation(Long memberId) {
return studentTransferStatusRepository.save(transferStatus);
}
+ // 여기는 readOnly를 유지한다. 페이지 조회는 목록과 전체 개수를 두 번에 나눠 읽으므로,
+ // 한 트랜잭션으로 묶어야 그 사이 들어온 쓰기 때문에 totalCount와 content가 어긋나지 않는다.
@Override
@Transactional(readOnly = true)
public PageResult
- * 계좌(은행/계좌번호)와 학기당 금액을 조회 시점에 조합한다 — 계좌만 바뀌거나 금액만 바뀌어도 항상 최신 값을
+ * 계좌와 학기당 금액을 조회 시점에 함께 읽는다 — 계좌만 바뀌거나 금액만 바뀌어도 항상 최신 값을
* 반영하기 위함(둘 다 admin_config_values에서 따로 관리, FeeConfigKeys 참고).
- * welfare 도메인은 internal 모듈에 의존할 수 없어 이 조합을 api에 둔다.
+ * welfare 도메인은 internal 모듈에 의존할 수 없어 이 조회를 api에 둔다.
*/
@Component
@RequiredArgsConstructor
@@ -23,11 +21,8 @@ public class FeeTransferUrlProvider {
private final AdminConfigValueService adminConfigValueService;
- /**
- * @param remainingSemesters 남은 학기 수. null이면 금액과 링크를 만들지 않는다
- * (아직 제출 전이거나, 확인필요라 금액을 보여주지 않는 경우)
- */
- public FeeTransferInfo getTransferInfo(Integer remainingSemesters) {
+ /** 계좌가 설정되지 않았으면 송금 자체가 불가능하므로 막는다. */
+ public FeeAccount getAccount() {
String bank = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null);
String accountNo = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null);
@@ -35,16 +30,10 @@ public FeeTransferInfo getTransferInfo(Integer remainingSemesters) {
throw new BusinessException(FeeErrorCode.FEE_ACCOUNT_NOT_FOUND);
}
- if (remainingSemesters == null) {
- return FeeTransferInfo.accountOnly(bank, accountNo);
- }
-
long amountPerSemester = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT)
.map(Long::parseLong)
.orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT);
- long transferAmount = FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester);
- return new FeeTransferInfo(bank, accountNo, transferAmount,
- TossTransferLinkGenerator.generate(bank, accountNo, transferAmount));
+ return new FeeAccount(bank, accountNo, amountPerSemester);
}
}
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 fb5e61a1..b7c09924 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java
@@ -1,7 +1,7 @@
package kr.ac.kookmin.stream.api.app.welfare.fee.response;
import java.time.LocalDateTime;
-import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferInfo;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;
@@ -22,26 +22,28 @@ public record FeeResponse(
String adminNote
) {
/**
- * 확인필요에서는 금액과 송금 링크를 내려주지 않는다. 아직 납부가 확인되지 않아 화면이 납부금액을
- * "-"로 두고, 송금 링크를 다시 주면 "또 보내라"로 읽히기 때문이다.
- * 재제출은 관리자가 납부 전으로 되돌린 뒤에 열린다.
+ * 금액과 송금 링크는 보낼 금액을 정할 수 있을 때만 내려준다. 제출 전이면 남은 학기 수를 모르고,
+ * 확인필요면 아직 납부가 확인되지 않아 화면이 납부금액을 "-"로 둔다. 확인필요에서 송금 링크를
+ * 다시 주면 "또 보내라"로 읽히는 것도 이유다. 재제출은 관리자가 납부 전으로 되돌린 뒤에 열린다.
*/
- public static FeeResponse of(StudentTransferStatus transferStatus, FeeTransferInfo transferInfo) {
- boolean paid = transferStatus.getStatus() == TransferStatus.PAID;
- boolean needsCheck = transferStatus.getStatus() == TransferStatus.NEEDS_CHECK;
+ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account) {
+ Integer remainingSemesters = transferStatus.getRemainingSemesters();
+ boolean payable = remainingSemesters != null && transferStatus.getStatus() != TransferStatus.NEEDS_CHECK;
+
+ Long transferAmount = payable ? account.transferAmountFor(remainingSemesters) : null;
return new FeeResponse(
transferStatus.getId(),
transferStatus.getStatus().name(),
- transferInfo.bank(),
- transferInfo.accountNo(),
- transferStatus.getRemainingSemesters(),
- needsCheck ? null : transferInfo.transferAmount(),
- needsCheck ? null : transferInfo.transferLinkUrl(),
+ account.bank(),
+ account.accountNo(),
+ remainingSemesters,
+ transferAmount,
+ transferAmount == null ? null : account.transferLinkFor(transferAmount),
transferStatus.getRequestedAt(),
// 납부 확인 시각은 PAID일 때의 처리 시각이다. 그 외 상태에서는 보여주지 않는다
- paid ? transferStatus.getReviewedAt() : null,
- needsCheck ? transferStatus.getAdminNote() : null
+ transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null,
+ transferStatus.getStatus() == TransferStatus.NEEDS_CHECK ? transferStatus.getAdminNote() : null
);
}
}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
index 6677e3c2..73eca1fb 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
@@ -1,6 +1,6 @@
package kr.ac.kookmin.stream.api.app.welfare.fee.response;
-import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferInfo;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
/** 납부 정보 제출 결과. 학생은 이 계좌와 금액으로 토스에서 송금한다. */
public record FeeSubmitResponse(
@@ -10,9 +10,11 @@ public record FeeSubmitResponse(
Long transferAmount,
String transferLinkUrl
) {
- public static FeeSubmitResponse of(int remainingSemesters, FeeTransferInfo transferInfo) {
+ public static FeeSubmitResponse of(int remainingSemesters, FeeAccount account) {
+ long transferAmount = account.transferAmountFor(remainingSemesters);
+
return new FeeSubmitResponse(
- transferInfo.bank(), transferInfo.accountNo(), remainingSemesters,
- transferInfo.transferAmount(), transferInfo.transferLinkUrl());
+ account.bank(), account.accountNo(), remainingSemesters,
+ transferAmount, account.transferLinkFor(transferAmount));
}
}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
index 69c58a8f..2a3861de 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
@@ -1,7 +1,7 @@
package kr.ac.kookmin.stream.api.app.welfare.fee.usecase;
import java.util.Objects;
-import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferInfo;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferUrlProvider;
import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest;
import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse;
@@ -42,7 +42,7 @@ public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) {
validateProofImage(request.proofImageKey(), memberId);
// 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다
- FeeTransferInfo transferInfo = feeTransferUrlProvider.getTransferInfo(remainingSemesters);
+ FeeAccount account = feeTransferUrlProvider.getAccount();
// 교체될 이전 키를 알려고 한 번 더 읽는다. submit이 돌려주게 하면 반환 타입에 값을 하나 더 얹어야 해서,
// 유니크 인덱스 조회 한 번을 받아들였다. 같은 학생의 동시 제출이 겹치면 낡은 키를 볼 수 있는데,
@@ -53,7 +53,7 @@ public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) {
deletePreviousProofImage(previousProofImageKey, saved.getProofImageKey());
- return FeeSubmitResponse.of(remainingSemesters, transferInfo);
+ return FeeSubmitResponse.of(remainingSemesters, account);
}
private int validateRemainingSemesters(Integer remainingSemesters) {
From c04398df2715e8be567ea2204c1811dd7ef82d69 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?=
<104981505+xeoxxn@users.noreply.github.com>
Date: Thu, 15 Oct 2026 15:29:33 +0900
Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20=EC=83=81?=
=?UTF-8?q?=ED=83=9C=EB=A5=BC=20=ED=9A=8C=EC=9B=90=20=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=B8=94=EC=97=90=20=EB=B3=B5=EC=82=AC=ED=95=98=EC=A7=80=20?=
=?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
members.fee_status는 회비 상태의 사본이라, 주인이 아닌 member 도메인에 welfare의
상태를 심고 두 값을 같은 트랜잭션에서 맞출 책임까지 양쪽에 지우는 구조였다.
회비 기능 때문에 Member와 MemberService를 고치는 것도 소유 범위를 벗어난다.
사본을 없애고 member 모듈 변경을 전부 되돌렸다. 관리자 목록이 납부 전 회원까지
보여주는 문제는 회원 기준으로 뒤집는 대신, 회비 쪽이 회원당 한 행을 갖게 해서 푼다
(관리자 PR에서 이어서 한다).
송금 확인 요청이 더는 두 도메인을 건드리지 않으므로 UseCase를 없애고 서비스를 바로 부른다.
---
.../api/app/welfare/fee/AppFeeController.java | 5 ++--
.../fee/usecase/FeeConfirmUseCase.java | 29 -------------------
.../member/domain/member/domain/Member.java | 29 +++----------------
.../domain/member/service/MemberService.java | 6 ----
.../service/impl/MemberServiceImpl.java | 8 -----
.../stream/db/member/MemberJpaEntity.java | 9 +-----
.../V20__add_fee_submission_columns.sql | 8 -----
7 files changed, 7 insertions(+), 87 deletions(-)
delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeConfirmUseCase.java
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 d4dbd85c..56c9e2cb 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java
@@ -5,7 +5,6 @@
import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeConfirmResponse;
import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse;
import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse;
-import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.FeeConfirmUseCase;
import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.FeeSubmitUseCase;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
@@ -25,7 +24,6 @@ public class AppFeeController implements AppFeeApi {
private final StudentTransferStatusService studentTransferStatusService;
private final FeeTransferUrlProvider feeTransferUrlProvider;
private final FeeSubmitUseCase feeSubmitUseCase;
- private final FeeConfirmUseCase feeConfirmUseCase;
@Override
@GetMapping("/me")
@@ -44,6 +42,7 @@ public ApiResponse
- * 실제 상태는 welfare 모듈의 {@code student_transfer_statuses}가 들고 있고, 여기 있는 값은 그 사본이다.
- * 관리자 납부자 목록이 "납부 전" 회원까지 보여줘야 하는데 그런 회원은 welfare 쪽에 행이 없어,
- * 회원 기준으로 검색·필터·페이징하려면 상태가 회원 쪽에도 있어야 한다.
- *
- * member 모듈은 이 값을 해석하지 않고 저장·비교만 하므로 welfare의 {@code TransferStatus}를 끌어오지 않고
- * 문자열로 둔다(도메인 모듈끼리는 서로를 모른다). 두 값은 항상 같은 트랜잭션에서 함께 바꾼다.
- */
- private String feeStatus;
// 로그인으로 처음 가입하는 회원. 운영진 권한·학생회 부서는 가입 뒤 따로 부여한다
public static Member create(String studentId, String name, Department department, String academicStatus) {
- return new Member(
- null, studentId, name, department, academicStatus, null, null, null, Role.STUDENT, null, UNPAID);
+ return new Member(null, studentId, name, department, academicStatus, null, null, null, Role.STUDENT, null);
}
public static Member of(
@@ -56,11 +41,10 @@ public static Member of(
String phoneNumber,
String fcmToken,
Role role,
- CouncilDepartment councilDepartment,
- String feeStatus
+ CouncilDepartment councilDepartment
) {
- return new Member(id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken,
- role, councilDepartment, feeStatus);
+ return new Member(
+ id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken, role, councilDepartment);
}
// 로그인할 때마다 provider의 최신 이름·학부·학적 상태로 갱신한다. 바뀐 값이 있으면 true를 돌려준다
@@ -76,11 +60,6 @@ public boolean updateProfile(String name, Department department, String academic
return true;
}
- // 회비 납부 상태를 welfare 쪽 변경에 맞춰 반영한다. 호출부가 두 값을 한 트랜잭션에서 함께 바꾼다
- public void changeFeeStatus(String feeStatus) {
- this.feeStatus = feeStatus;
- }
-
public void registerPhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java
index 5defbcdb..39d1a0fd 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java
@@ -7,12 +7,6 @@
import kr.ac.kookmin.stream.member.domain.member.domain.MemberSignUpCommand;
public interface MemberService {
-
- /**
- * 회비 납부 상태를 반영한다. 실제 상태는 welfare가 들고 있고 여기 값은 사본이므로,
- * 호출부(UseCase)가 두 값을 한 트랜잭션에서 함께 바꾼다.
- */
- Member changeFeeStatus(Long memberId, String feeStatus);
Member getById(Long id);
List