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 findById(Long id); + Optional findByFileKey(String fileKey); List findAllByIdIn(List ids); File save(File file); void deleteById(Long id); diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java index 444c3c8b..5b81fc42 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java @@ -11,5 +11,8 @@ public interface FileService { FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command); void delete(Long fileId); Optional findById(Long fileId); + + /** 업로드 URL 발급 때 돌려준 키로 찾는다. 제출받은 키가 실재하는지, 누가 올렸는지 확인할 때 쓴다. */ + Optional findByFileKey(String fileKey); Map findAllByIdIn(List fileIds); } diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java index 003f0169..8812e667 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java @@ -63,6 +63,11 @@ public Optional findById(Long fileId) { return fileRepository.findById(fileId); } + @Override + public Optional findByFileKey(String fileKey) { + return fileRepository.findByFileKey(fileKey); + } + @Override public Map findAllByIdIn(List fileIds) { if (fileIds.isEmpty()) { diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java index 6466a7e8..f78bd77b 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java @@ -22,7 +22,8 @@ FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS), FileCategory.NOTICE_IMAGE, IMAGE_EXTENSIONS, FileCategory.EVENT_IMAGE, IMAGE_EXTENSIONS, FileCategory.ARCHIVE_IMAGE, IMAGE_EXTENSIONS, - FileCategory.LOCKER_SECTION_PHOTO, IMAGE_EXTENSIONS + FileCategory.LOCKER_SECTION_PHOTO, IMAGE_EXTENSIONS, + FileCategory.FEE_TRANSCRIPT_IMAGE, IMAGE_EXTENSIONS ); private static final Map MAX_FILE_SIZE = Map.of( @@ -31,7 +32,8 @@ FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS), FileCategory.NOTICE_IMAGE, IMAGE_MAX_FILE_SIZE, FileCategory.EVENT_IMAGE, IMAGE_MAX_FILE_SIZE, FileCategory.ARCHIVE_IMAGE, IMAGE_MAX_FILE_SIZE, - FileCategory.LOCKER_SECTION_PHOTO, IMAGE_MAX_FILE_SIZE + FileCategory.LOCKER_SECTION_PHOTO, IMAGE_MAX_FILE_SIZE, + FileCategory.FEE_TRANSCRIPT_IMAGE, IMAGE_MAX_FILE_SIZE ); private FileUploadPolicy() {} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java index 9b61d699..cb534fde 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java @@ -1,8 +1,12 @@ package kr.ac.kookmin.stream.db.file; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface FileJpaRepository extends JpaRepository { List findAllByIdIn(List ids); + + // file_key는 TEXT라 idx_files_file_key가 prefix(255) 인덱스다(V20) + Optional findByFileKey(String fileKey); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java index 2e1fe512..5d25ebbc 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java @@ -18,6 +18,11 @@ public Optional findById(Long id) { return fileJpaRepository.findById(id).map(FileJpaEntity::toDomain); } + @Override + public Optional findByFileKey(String fileKey) { + return fileJpaRepository.findByFileKey(fileKey).map(FileJpaEntity::toDomain); + } + @Override public List findAllByIdIn(List ids) { return fileJpaRepository.findAllByIdIn(ids).stream().map(FileJpaEntity::toDomain).toList(); From 88b66f5a912d2d9abcf05d941f6ee7d3ed3a1e89 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:08:02 +0900 Subject: [PATCH 4/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=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?API=EB=A5=BC=20=EC=A0=9C=EC=B6=9C=C2=B7=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=202=EB=8B=A8=EA=B3=84=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조회에서 학년·학기 파라미터를 없애고 납부 계좌·금액·안내 문구를 함께 내려준다. 제출은 남은 학기 수와 학점이수현황 캡처를 받고, 송금 확인 요청을 별도 엔드포인트로 분리했다. 금액 계산 기준이 학년·학기에서 학생이 입력한 남은 학기 수로 바뀌어 GradeSemester는 쓰이지 않는다. 재제출로 교체된 이전 캡처는 그 자리에서 지운다. 삭제는 R2 호출이라 롤백되지 않으므로 저장 트랜잭션 커밋 뒤에 하고, 실패해도 제출은 성공으로 둔다. --- .../stream/api/app/welfare/fee/AppFeeApi.java | 55 ++++++----- .../api/app/welfare/fee/AppFeeController.java | 51 ++++++---- .../api/app/welfare/fee/FeeAccount.java | 10 ++ .../welfare/fee/FeeTransferUrlProvider.java | 28 +++--- .../fee/request/FeeConfirmRequest.java | 11 --- .../welfare/fee/request/FeeSubmitRequest.java | 18 ++++ .../fee/response/FeeConfirmResponse.java | 16 +++ .../app/welfare/fee/response/FeeResponse.java | 34 ++++++- .../fee/response/FeeSubmitResponse.java | 18 ++++ .../fee/usecase/FeeConfirmUseCase.java | 29 ++++++ .../welfare/fee/usecase/FeeSubmitUseCase.java | 97 +++++++++++++++++++ .../domain/member/service/MemberService.java | 6 ++ .../service/impl/MemberServiceImpl.java | 8 ++ .../domain/fee/domain/FeeErrorCode.java | 1 - .../domain/fee/domain/GradeSemester.java | 24 ----- .../fee/service/FeeAmountCalculator.java | 12 +-- 16 files changed, 314 insertions(+), 104 deletions(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeConfirmRequest.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeConfirmUseCase.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java delete mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/GradeSemester.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java index df7e566e..9316b183 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java @@ -2,41 +2,48 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; -import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.app.AppApiUser; -import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeConfirmRequest; +import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; +import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeConfirmResponse; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; -import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; /** * 학생 앱 학생회비 API의 문서 명세. 구현은 {@link AppFeeController}가 맡는다. *

- * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 - * 파라미터 바인딩(@{@code RequestParam}, @{@code RequestBody} 등)은 구현체에 둔다. + * 납부는 두 단계다. 납부 정보를 제출해도 상태는 납부 전에 머물고, 토스로 송금한 뒤 + * 송금 확인을 요청해야 납부확인중이 된다. 제출만 하고 이탈한 학생을 운영진이 + * 처리 대기로 보지 않기 위해서다. + *

+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. */ @Tag(name = "학생회비", description = "학생 앱 학생회비 납부") public interface AppFeeApi { - /** 내 학생회비 납부 상태와 송금 링크. 학년·학기를 지정하면 해당 학기의 송금 링크를 내려준다. */ + /** 내 학생회비 납부 현황. 화면은 feeStatus와 studentTransferStatusId로 분기한다. */ @Operation(summary = "내 학생회비 상태 조회", - description = "로그인한 학생의 납부 상태와 함께 지정한 학년·학기의 송금 링크를 조회한다.") - @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_TRANSFER_LINK_INVALID"}) - ApiResponse getFeeByMemberId( - AppApiUser apiUser, - Integer grade, - Integer semester - ); + description = "로그인한 학생의 납부 상태와 납부 계좌, 납부 금액과 송금 링크를 조회한다. " + + "납부 정보를 제출하기 전이거나 확인필요 상태면 금액과 송금 링크는 내려가지 않는다.") + @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_ACCOUNT_NOT_FOUND"}) + ApiResponse getFeeByMemberId(AppApiUser apiUser); + + /** 납부 정보 제출. 상태는 납부 전 그대로이고, 돌려받은 송금 링크로 토스에서 송금한다. */ + @Operation(summary = "학생회비 납부 정보 제출", + description = "남은 학기 수와 학점이수현황 캡처를 제출하고, 납부 금액이 붙은 토스 송금 링크를 받는다. " + + "상태는 납부 전 그대로이며 이전에 제출한 값과 이미지는 덮어쓴다. " + + "확인필요로 처리된 학생은 직접 다시 제출할 수 없고 운영진이 납부 전으로 되돌려야 한다.") + @ApiErrorCode(type = FeeErrorCode.class, + codes = {"INVALID_REMAINING_SEMESTERS", "INVALID_PROOF_IMAGE", "FEE_ACCOUNT_NOT_FOUND", + "FEE_REQUEST_ALREADY_REQUESTED"}) + ApiResponse submit(AppApiUser apiUser, FeeSubmitRequest request); - /** 납부 확인 요청. 송금 후 운영진 확인을 요청하는 상태로 전환한다. */ - @Operation(summary = "학생회비 납부 확인 요청", - description = "송금을 마친 학생이 운영진의 납부 확인을 요청한다.") - @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_TRANSFER_LINK_INVALID"}) - ApiResponse requestConfirmation( - AppApiUser apiUser, - FeeConfirmRequest request - ); + /** 송금 확인 요청. 여기서 비로소 납부확인중으로 바뀐다. */ + @Operation(summary = "학생회비 송금 확인 요청", + description = "송금을 마친 학생이 운영진의 확인을 요청한다. 납부 정보를 제출한 학생만 요청할 수 있다.") + @ApiErrorCode(type = FeeErrorCode.class, + codes = {"FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_ALREADY_REQUESTED"}) + ApiResponse requestConfirmation(AppApiUser apiUser); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java index c156ce44..9984361e 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,21 @@ package kr.ac.kookmin.stream.api.app.welfare.fee; -import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.app.AppApiUser; -import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeConfirmRequest; +import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; +import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeConfirmResponse; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.GradeSemester; +import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse; +import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.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; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -22,29 +25,39 @@ public class AppFeeController implements AppFeeApi { private final StudentTransferStatusService studentTransferStatusService; private final FeeTransferUrlProvider feeTransferUrlProvider; + private final FeeSubmitUseCase feeSubmitUseCase; + private final FeeConfirmUseCase feeConfirmUseCase; @Override @GetMapping("/me") - public ApiResponse getFeeByMemberId( - AppApiUser apiUser, - @RequestParam(required = false) Integer grade, - @RequestParam(required = false) Integer semester - ) { - GradeSemester gradeSemester = GradeSemester.of(grade, semester); + public ApiResponse getFeeByMemberId(AppApiUser apiUser) { StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId()); - return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getTransferUrl(gradeSemester))); + return ApiResponse.success( + FeeResponse.of(transferStatus, feeTransferUrlProvider.getAccount(amountBaseOf(transferStatus)))); } @Override @PostMapping - public ApiResponse requestConfirmation( - AppApiUser apiUser, - @RequestBody FeeConfirmRequest request - ) { - GradeSemester gradeSemester = request.toGradeSemester(); - StudentTransferStatus transferStatus = studentTransferStatusService.requestConfirmation(apiUser.userId()); - - return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getTransferUrl(gradeSemester))); + public ApiResponse submit(AppApiUser apiUser, @RequestBody FeeSubmitRequest request) { + return ApiResponse.success(feeSubmitUseCase.submit(apiUser.userId(), request)); + } + + @Override + @PostMapping("/requests/confirm") + public ApiResponse requestConfirmation(AppApiUser apiUser) { + return ApiResponse.success(feeConfirmUseCase.confirm(apiUser.userId())); + } + + /** + * 금액·송금 링크를 만들 기준이 되는 남은 학기 수. 없으면 null이라 금액과 링크가 빠진다. + *

+ * 확인필요는 아직 납부가 확인되지 않은 상태라 화면이 납부금액을 "-"로 두고, 송금 링크를 다시 주면 + * "또 보내라"로 읽히므로 함께 뺀다. 재제출은 관리자가 납부 전으로 되돌린 뒤에 열린다. + */ + 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 findAllByIds(List ids); Map getMapByIds(List ids); 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 f6bdf2d2..9ec85614 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 @@ -48,6 +48,14 @@ public List searchIdsByKeyword(String keyword) { return memberRepository.searchIdsByKeyword(keyword); } + @Override + @Transactional + public Member changeFeeStatus(Long memberId, String feeStatus) { + Member member = getById(memberId); + member.changeFeeStatus(feeStatus); + return memberRepository.save(member); + } + @Override @Transactional public Member updateProfile(Long id, MemberProfileCommand command) { 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 8a704c6f..d3f0ab5e 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java @@ -19,7 +19,6 @@ public enum FeeErrorCode implements ErrorCode { FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "제출된 납부 정보가 없습니다."), FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."), FEE_REQUEST_ALREADY_REVIEWED(ErrorStatus.CONFLICT, "이미 처리된 납부 확인 요청입니다."), - FEE_TRANSFER_LINK_INVALID(ErrorStatus.INTERNAL_SERVER_ERROR, "송금 요청 URL이 생성되지 않았습니다. 관리자에게 문의해 주세요."), MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다."); private final int status; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/GradeSemester.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/GradeSemester.java deleted file mode 100644 index 8410090f..00000000 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/GradeSemester.java +++ /dev/null @@ -1,24 +0,0 @@ -package kr.ac.kookmin.stream.welfare.domain.fee.domain; - -/** - * 학년(1~4)·학기(1~2)를 묶은 값 객체. 항상 유효한 상태로만 생성되도록 {@link #of}에서 검증한다. - */ -public record GradeSemester(int grade, int semester) { - - private static final int MIN_GRADE = 1; - private static final int MAX_GRADE = 4; - private static final int MIN_SEMESTER = 1; - private static final int MAX_SEMESTER = 2; - - // 값이 없거나(null) 범위를 벗어나면 IllegalArgumentException을 던진다. - // (웹 계층에서 파라미터로 받으므로 미입력까지 여기서 함께 걸러낸다.) - public static GradeSemester of(Integer grade, Integer semester) { - if (grade == null || grade < MIN_GRADE || grade > MAX_GRADE) { - throw new IllegalArgumentException("학년은 " + MIN_GRADE + "~" + MAX_GRADE + " 사이여야 합니다."); - } - if (semester == null || semester < MIN_SEMESTER || semester > MAX_SEMESTER) { - throw new IllegalArgumentException("학기는 " + MIN_SEMESTER + "~" + MAX_SEMESTER + " 사이여야 합니다."); - } - return new GradeSemester(grade, semester); - } -} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java index af2ef180..b61d941c 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java @@ -1,18 +1,12 @@ package kr.ac.kookmin.stream.welfare.domain.fee.service; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.GradeSemester; - -// 4년(8학기)치 회비를 남은 학기 수만큼만 걷는다 — (전체 8학기 - 이수완료학기) * 학기당 금액. -// 이수완료학기는 학년·학기로 계산한다: 1학년 1학기면 0학기 이수, 2학년 1학기면 2학기 이수하는 식. +// 남은 학기 수만큼만 회비를 걷는다 — 남은 학기 수 × 학기당 금액. +// 남은 학기 수는 학생이 직접 입력하고 학점이수현황 캡처로 증빙한다(예전에는 학년·학기로 계산했다). public final class FeeAmountCalculator { - private static final int TOTAL_SEMESTERS = 8; - private FeeAmountCalculator() {} - public static long calculate(GradeSemester gradeSemester, long amountPerSemester) { - int completedSemesters = (gradeSemester.grade() - 1) * 2 + (gradeSemester.semester() - 1); - int remainingSemesters = Math.max(TOTAL_SEMESTERS - completedSemesters, 0); + public static long calculate(int remainingSemesters, long amountPerSemester) { return (long) remainingSemesters * amountPerSemester; } } From 77a29bb3c7fca8a926f6b07303bf70bac12fd077 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:22:56 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EA=B3=BC=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A1=B0=EB=A6=BD=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단건 조회인 getByMemberId에서 readOnly 트랜잭션을 뗐다(PR#67/#69 원칙). 페이지 조회는 목록과 전체 개수를 두 번에 나눠 읽어 한 스냅샷이 필요하므로 유지하고 근거를 주석으로 남겼다. 확인필요일 때 금액·송금 링크를 빼는 규칙이 컨트롤러와 응답 조립부 두 곳에 있어 응답 쪽으로 모았다. FeeAccount는 금액·링크까지 담고 있어 FeeTransferInfo로 바꿨다. --- .../api/app/welfare/fee/AppFeeController.java | 17 ++--------------- .../stream/api/app/welfare/fee/FeeAccount.java | 10 ---------- .../api/app/welfare/fee/FeeTransferInfo.java | 14 ++++++++++++++ .../app/welfare/fee/FeeTransferUrlProvider.java | 6 +++--- .../app/welfare/fee/response/FeeResponse.java | 17 +++++++++++------ .../welfare/fee/response/FeeSubmitResponse.java | 8 ++++---- .../welfare/fee/usecase/FeeSubmitUseCase.java | 9 ++++++--- .../impl/StudentTransferStatusServiceImpl.java | 6 ++++-- 8 files changed, 44 insertions(+), 43 deletions(-) delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferInfo.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 9984361e..3166e147 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 @@ -9,7 +9,6 @@ import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.FeeSubmitUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -32,9 +31,9 @@ public class AppFeeController implements AppFeeApi { @GetMapping("/me") public ApiResponse getFeeByMemberId(AppApiUser apiUser) { StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId()); + FeeTransferInfo transferInfo = feeTransferUrlProvider.getTransferInfo(transferStatus.getRemainingSemesters()); - return ApiResponse.success( - FeeResponse.of(transferStatus, feeTransferUrlProvider.getAccount(amountBaseOf(transferStatus)))); + return ApiResponse.success(FeeResponse.of(transferStatus, transferInfo)); } @Override @@ -48,16 +47,4 @@ public ApiResponse submit(AppApiUser apiUser, @RequestBody Fe public ApiResponse requestConfirmation(AppApiUser apiUser) { return ApiResponse.success(feeConfirmUseCase.confirm(apiUser.userId())); } - - /** - * 금액·송금 링크를 만들 기준이 되는 남은 학기 수. 없으면 null이라 금액과 링크가 빠진다. - *

- * 확인필요는 아직 납부가 확인되지 않은 상태라 화면이 납부금액을 "-"로 두고, 송금 링크를 다시 주면 - * "또 보내라"로 읽히므로 함께 뺀다. 재제출은 관리자가 납부 전으로 되돌린 뒤에 열린다. - */ - 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 search(TransferStatus status, List memberIds, PageOffset pageOffset) { From 8b55df4d44f6d2dfc97b0270b9001f153f77889b 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:30:15 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20=EB=82=A9?= =?UTF-8?q?=EB=B6=80=20=EC=84=A4=EC=A0=95=EA=B3=BC=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FeeTransferInfo는 계좌·금액·링크를 한데 담았는데, 셋이 한 개념이라서가 아니라 두 응답이 같은 필드를 써서 묶인 타입이었다. 링크가 나머지 값으로 만들어진 것이라 입력과 출력이 한 record에 같이 있는 문제도 있었다. 관리자가 설정하는 값(은행·계좌번호·학기당 회비)만 FeeAccount로 묶고, 학생마다 달라지는 납부 금액과 송금 링크는 그 계좌가 계산해 주는 결과로 바꿨다. --- .../api/app/welfare/fee/AppFeeController.java | 3 +- .../api/app/welfare/fee/FeeAccount.java | 23 ++++++++++++++ .../api/app/welfare/fee/FeeTransferInfo.java | 14 --------- .../welfare/fee/FeeTransferUrlProvider.java | 23 ++++---------- .../app/welfare/fee/response/FeeResponse.java | 30 ++++++++++--------- .../fee/response/FeeSubmitResponse.java | 10 ++++--- .../welfare/fee/usecase/FeeSubmitUseCase.java | 6 ++-- 7 files changed, 55 insertions(+), 54 deletions(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferInfo.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 3166e147..d4dbd85c 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 @@ -31,9 +31,8 @@ public class AppFeeController implements AppFeeApi { @GetMapping("/me") public ApiResponse getFeeByMemberId(AppApiUser apiUser) { StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId()); - FeeTransferInfo transferInfo = feeTransferUrlProvider.getTransferInfo(transferStatus.getRemainingSemesters()); - return ApiResponse.success(FeeResponse.of(transferStatus, transferInfo)); + return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getAccount())); } @Override diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java new file mode 100644 index 00000000..a2812b66 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.api.app.welfare.fee; + +import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; +import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator; + +/** + * 관리자가 설정한 회비 납부 설정. 셋 다 {@code admin_config_values}에 들어 있고 같이 읽힌다. + * 계좌는 전역 하나이며, 학생마다 다른 것은 금액뿐이다. + * + * @param amountPerSemester 학기당 회비 + */ +public record FeeAccount(String bank, String accountNo, long amountPerSemester) { + + /** 이 학생이 보낼 금액. 남은 학기 수 × 학기당 회비. */ + public long transferAmountFor(int remainingSemesters) { + return FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester); + } + + /** 금액이 붙은 토스 송금 링크. */ + public String transferLinkFor(long transferAmount) { + return TossTransferLinkGenerator.generate(bank, accountNo, transferAmount); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferInfo.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferInfo.java deleted file mode 100644 index 06bb4d42..00000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferInfo.java +++ /dev/null @@ -1,14 +0,0 @@ -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 538e5801..ea3493ed 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java @@ -5,17 +5,15 @@ import kr.ac.kookmin.stream.welfare.WelfareConstants; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; -import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; -import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; /** - * 회비 납부 계좌 조회와 송금 링크 생성을 담당하는 파사드. + * 회비 납부 설정을 읽어 온다. *

- * 계좌(은행/계좌번호)와 학기당 금액을 조회 시점에 조합한다 — 계좌만 바뀌거나 금액만 바뀌어도 항상 최신 값을 + * 계좌와 학기당 금액을 조회 시점에 함께 읽는다 — 계좌만 바뀌거나 금액만 바뀌어도 항상 최신 값을 * 반영하기 위함(둘 다 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 submit(AppApiUser apiUser, @RequestBody Fe @Override @PostMapping("/requests/confirm") public ApiResponse requestConfirmation(AppApiUser apiUser) { - return ApiResponse.success(feeConfirmUseCase.confirm(apiUser.userId())); + return ApiResponse.success( + FeeConfirmResponse.from(studentTransferStatusService.requestConfirmation(apiUser.userId()))); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeConfirmUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeConfirmUseCase.java deleted file mode 100644 index 0f6f284a..00000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeConfirmUseCase.java +++ /dev/null @@ -1,29 +0,0 @@ -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/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 0ca0c2c9..158bc599 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,9 +13,6 @@ @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; @@ -28,22 +25,10 @@ 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, 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 findAllByIds(List ids); Map getMapByIds(List ids); 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 9ec85614..f6bdf2d2 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 @@ -48,14 +48,6 @@ public List searchIdsByKeyword(String keyword) { return memberRepository.searchIdsByKeyword(keyword); } - @Override - @Transactional - public Member changeFeeStatus(Long memberId, String feeStatus) { - Member member = getById(memberId); - member.changeFeeStatus(feeStatus); - return memberRepository.save(member); - } - @Override @Transactional public Member updateProfile(Long id, MemberProfileCommand command) { 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 27e6b014..8a084ba7 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,11 +60,6 @@ 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(); @@ -76,7 +71,6 @@ 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) { @@ -85,7 +79,6 @@ public static MemberJpaEntity from(Member member) { public Member toDomain() { return Member.of( - id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken, - role, councilDepartment, feeStatus); + id, studentId, name, department, academicStatus, email, phoneNumber, fcmToken, role, councilDepartment); } } 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 index 11e6f82f..d289fbc1 100644 --- 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 @@ -7,14 +7,6 @@ ALTER TABLE student_transfer_statuses 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 f56f09e363616a615c2260085d6517e34ada93df 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:30:40 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20.env=EB=A5=BC=20git=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index da63ff01..958dd7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ docs/plans # Firebase 서비스 계정 키 (env로 주입하고 파일은 커밋하지 않는다) *firebase-adminsdk*.json + +# 로컬 환경변수. .env.example만 저장소에 둔다 +.env +.env.local