Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ ApiResponse<PageResponse<AdminFeeSearchResponse>> getRequests(
@Operation(summary = "학생회비 납부 상태 수정",
description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. "
+ "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. "
+ "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다.")
+ "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다. "
+ "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다.")
@ApiErrorCode(type = FeeErrorCode.class,
codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND"})
codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_NOT_LATEST"})
ApiResponse<FeeStatusUpdateResponse> updateStatus(
AdminApiUser apiUser,
Long studentTransferStatusId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ public interface AppFeeApi {
@Operation(summary = "학생회비 납부 요청",
description = "토스로 송금을 마친 학생이 남은 학기 수와 학점이수현황 캡처를 함께 보내 운영진의 확인을 요청한다. "
+ "요청 시점의 금액(남은 학기 수 × 학기당 회비)을 함께 남긴다. "
+ "처음 내는 학생과 확인필요로 돌려받은 학생만 요청할 수 있고, 돌려받은 학생은 이전 요청을 덮어쓴다.")
+ "처음 내는 학생과 확인필요로 돌려받은 학생만 요청할 수 있다. 돌려받은 학생이 다시 내면 새 요청으로 쌓이고 "
+ "이전 요청은 그대로 남는다.")
@ApiErrorCode(type = FeeErrorCode.class,
codes = {"INVALID_REMAINING_SEMESTERS", "INVALID_PROOF_IMAGE", "FEE_ACCOUNT_NOT_FOUND",
"FEE_REQUEST_ALREADY_REQUESTED"})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public ApiResponse<FeeResponse> getFeeByMemberId(
: FeeAmountCalculator.validateRemainingSemesters(remainingSemesters);
FeeAccount account = feeAccountProvider.getAccount();

FeeResponse response = studentTransferStatusService.findByMemberId(apiUser.userId())
FeeResponse response = studentTransferStatusService.findLatestByMemberId(apiUser.userId())
.map(transferStatus -> FeeResponse.of(transferStatus, account, semesters))
.orElseGet(() -> FeeResponse.notRequested(account, semesters));
return ApiResponse.success(response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,21 @@
import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator;
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}(인증 이미지) 두 도메인을 조합한다.
* <p>
* 메서드에 {@code @Transactional}을 붙이지 않는다. 저장은 {@code StudentTransferStatusService.request}가
* 자기 트랜잭션에서 끝내고, 이전 이미지 삭제는 R2 호출이라 롤백되지 않아 그 커밋 뒤에 해야 하기 때문이다
* ({@code architecture.md} 6-1절 "롤백 불가한 외부 부수효과").
* 메서드에 {@code @Transactional}을 붙이지 않는다. 쓰기는 {@code StudentTransferStatusService.request} 하나뿐이고
* 그 트랜잭션 안에서 끝난다. 인증 이미지는 조회해서 검증만 한다.
* <p>
* 다시 내도 이전 인증 이미지를 지우지 않는다. 반려된 이전 요청이 이력으로 남아 그 이미지를 계속 가리킨다.
*/
@Component
@RequiredArgsConstructor
public class FeeSubmitUseCase {

private static final Logger log = LoggerFactory.getLogger(FeeSubmitUseCase.class);

private final StudentTransferStatusService studentTransferStatusService;
private final FeeAccountProvider feeAccountProvider;
private final FileService fileService;
Expand All @@ -44,17 +41,8 @@ public FeeResponse submit(Long memberId, FeeSubmitRequest request) {
FeeAccount account = feeAccountProvider.getAccount();
long amount = account.calculateTransferAmount(remainingSemesters);

// 반려 후 다시 내면 이전 요청을 덮어쓰므로, 교체될 이전 키를 알려고 한 번 더 읽는다. request가 돌려주게 하면
// 반환 타입에 값을 하나 더 얹어야 해서, 유니크 인덱스 조회 한 번을 받아들였다. 같은 학생의 동시 요청이 겹치면
// 낡은 키를 볼 수 있는데, 그때 생기는 건 고아 파일 하나뿐이라 파일 정리 배치가 걷어간다.
String previousProofImageKey = studentTransferStatusService.findByMemberId(memberId)
.map(StudentTransferStatus::getProofImageKey)
.orElse(null);
StudentTransferStatus saved =
studentTransferStatusService.request(memberId, remainingSemesters, request.proofImageKey(), amount);

deletePreviousProofImage(previousProofImageKey, saved.getProofImageKey());

return FeeResponse.of(saved, account, null);
}

Expand All @@ -73,21 +61,4 @@ private void validateProofImage(String proofImageKey, Long memberId) {
throw new BusinessException(FeeErrorCode.INVALID_PROOF_IMAGE);
}
}

/**
* 다시 내면서 교체된 이전 이미지를 지운다. 아무도 가리키지 않게 된 데다 학번·이름이 찍혀 있어 남겨둘 이유가 없다.
* <p>
* 삭제가 실패해도 요청은 성공으로 둔다 — 고아 파일 하나가 남는 편이 요청 실패보다 낫다.
* 남은 것은 파일 정리 배치가 걷어간다.
*/
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public enum FeeErrorCode implements ErrorCode {
FEE_ACCOUNT_NOT_FOUND(ErrorStatus.NOT_FOUND, "학생회비 납부 계좌가 등록되지 않았습니다."),
FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "제출된 납부 정보가 없습니다."),
FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."),
FEE_REQUEST_NOT_LATEST(ErrorStatus.CONFLICT, "회원의 최신 납부 요청만 처리할 수 있습니다."),
MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다.");

private final int status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public class StudentTransferStatus {
private LocalDateTime updatedAt;

/**
* 송금을 마친 학생의 첫 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를
* 한 번에 받으므로 바로 납부확인중이 된다.
* 송금을 마친 학생의 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 한 번에 받으므로
* 바로 납부확인중이 된다. 처음 내든 반려 뒤 다시 내든 늘 새 요청(행)이고, 이전 요청은 이력으로 남는다.
*/
public static StudentTransferStatus request(
Long memberId,
Expand Down Expand Up @@ -60,23 +60,13 @@ public static StudentTransferStatus of(
}

/**
* 반려된 학생이 학기 인증부터 다시 납부한다. 이전 요청의 입력값과 처리 흔적(안내 문구·처리 시각)을
* 지우고 새 요청으로 만든다.
* <p>
* 반려에서만 가능하다. 납부확인중이면 이미 운영진에게 요청이 가 있고, 납부완료면 더 낼 것이 없다.
* 이 요청 다음에 새 납부 요청을 받을 수 있는지 본다. 반려된 요청 뒤에만 다시 낼 수 있다 —
* 납부확인중이면 이미 운영진에게 요청이 가 있고, 납부완료면 더 낼 것이 없다.
*/
public void resubmit(int remainingSemesters, String proofImageKey, long amount, LocalDateTime now) {
public void validateRequestable() {
if (status != TransferStatus.REJECTED) {
throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED);
}

this.status = TransferStatus.PENDING;
this.remainingSemesters = remainingSemesters;
this.proofImageKey = proofImageKey;
this.amount = amount;
this.adminNote = null;
this.requestedAt = now;
this.reviewedAt = null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;

// 납부 요청은 이력으로 쌓이므로 회원 기준 조회는 모두 최신 요청(PK가 가장 큰 행) 하나만 돌려준다
public interface StudentTransferStatusRepository {
Optional<StudentTransferStatus> findByMemberId(Long memberId);
Optional<StudentTransferStatus> findLatestByMemberId(Long memberId);
Optional<StudentTransferStatus> findById(Long id);
List<StudentTransferStatus> findAllByMemberIds(List<Long> memberIds);
List<StudentTransferStatus> findLatestByMemberIds(List<Long> memberIds);

// memberIds가 null이면 전체 대상, 빈 리스트면 결과 없음을 의도한 것이므로 호출 전에 걸러낸다(빈 리스트로 호출하지 않는다)
// 회원별 최신 요청만 대상으로 한다. memberIds가 null이면 전체 대상, 빈 리스트면 결과 없음을 의도한 것이므로 호출 전에 걸러낸다(빈 리스트로 호출하지 않는다)
PageResult<StudentTransferStatus> search(TransferStatus status, List<Long> memberIds, PageOffset pageOffset);

StudentTransferStatus save(StudentTransferStatus transferStatus);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,26 @@

public interface StudentTransferStatusService {

/** 회원의 납부 요청. 한 번도 요청하지 않은 회원(미납부)이면 비어 있다. */
Optional<StudentTransferStatus> findByMemberId(Long memberId);
/** 회원의 최신 납부 요청 — 곧 회원의 지금 상태다. 한 번도 요청하지 않은 회원(미납부)이면 비어 있다. */
Optional<StudentTransferStatus> findLatestByMemberId(Long memberId);

/**
* 학기 인증과 송금 완료를 한 번에 받아 납부확인중으로 만든다. 처음이면 새로 만들고, 반려된 요청이면 다시 낸다.
* 납부확인중·납부완료면 {@code FEE_REQUEST_ALREADY_REQUESTED}다.
* 학기 인증과 송금 완료를 한 번에 받아 납부확인중인 새 요청을 만든다. 처음이든 반려 뒤든 늘 새 행이고,
* 이전 요청은 그대로 남는다. 최신 요청이 납부확인중·납부완료면 {@code FEE_REQUEST_ALREADY_REQUESTED}다.
*
* @param amount 요청 시점에 안내한 납부 금액
*/
StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount);

/** 회원별 납부 상태. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 한 번도 요청하지 않은 회원은 빠진다. */
/** 회원별 최신 납부 요청. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 한 번도 요청하지 않은 회원은 빠진다. */
Map<Long, StudentTransferStatus> getMapByMemberIds(List<Long> memberIds);

// status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음
PageResult<StudentTransferStatus> search(TransferStatus status, List<Long> memberIds, PageOffset pageOffset);

/** 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다. */
/**
* 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다.
* 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다.
*/
StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,29 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService {

// 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다).
@Override
public Optional<StudentTransferStatus> findByMemberId(Long memberId) {
return studentTransferStatusRepository.findByMemberId(memberId);
public Optional<StudentTransferStatus> findLatestByMemberId(Long memberId) {
return studentTransferStatusRepository.findLatestByMemberId(memberId);
}

@Override
public Map<Long, StudentTransferStatus> getMapByMemberIds(List<Long> memberIds) {
if (memberIds.isEmpty()) {
return Map.of();
}
return studentTransferStatusRepository.findAllByMemberIds(memberIds).stream()
return studentTransferStatusRepository.findLatestByMemberIds(memberIds).stream()
.collect(Collectors.toMap(StudentTransferStatus::getMemberId, Function.identity()));
}

@Override
@Transactional
public StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount) {
LocalDateTime now = LocalDateTime.now(DateUtil.KST);
StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId)
.map(existing -> {
existing.resubmit(remainingSemesters, proofImageKey, amount, now);
return existing;
})
.orElseGet(() -> StudentTransferStatus.request(memberId, remainingSemesters, proofImageKey, amount, now));
// 반려된 요청 뒤에만 새 요청을 받는다. 이전 요청은 고치지 않고 이력으로 남긴다
studentTransferStatusRepository.findLatestByMemberId(memberId)
.ifPresent(StudentTransferStatus::validateRequestable);

// 같은 회원의 동시 요청은 위 검사를 함께 통과할 수 있다. 그때는 진행 중 요청 유니크(V26)가 두 번째 저장을 막는다
StudentTransferStatus transferStatus = StudentTransferStatus.request(
memberId, remainingSemesters, proofImageKey, amount, LocalDateTime.now(DateUtil.KST));
return studentTransferStatusRepository.save(transferStatus);
}

Expand All @@ -66,8 +66,20 @@ public PageResult<StudentTransferStatus> search(TransferStatus status, List<Long
public StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote) {
StudentTransferStatus transferStatus = studentTransferStatusRepository.findById(transferStatusId)
.orElseThrow(() -> new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND));
requireLatest(transferStatus);

transferStatus.review(status, adminNote, LocalDateTime.now(DateUtil.KST));
return studentTransferStatusRepository.save(transferStatus);
}

// 지난 요청은 이력이라 바꾸지 않는다. 회원의 지금 상태는 최신 요청 하나뿐이고, 지난 요청을 되살리면
// 회원당 진행 중 요청이 둘이 될 수 있다
private void requireLatest(StudentTransferStatus transferStatus) {
boolean latest = studentTransferStatusRepository.findLatestByMemberId(transferStatus.getMemberId())
.map(found -> found.getId().equals(transferStatus.getId()))
.orElse(false);
if (!latest) {
throw new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_LATEST);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
import kr.ac.kookmin.stream.db.common.BaseTimeEntity;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
Expand All @@ -20,10 +20,12 @@
* 현재는 학생회장/총무부장 등 소수 인원만 처리 권한을 가져 낙관적 락(@Version)을 쓰지 않는다.
* 처리 권한자가 여러 명으로 늘어나면 동시 처리 경합을 막기 위해 @Version 도입을 검토한다.
*/
// uk_student_transfer_statuses_active_member_id(V26)는 generated column active_member_id에 걸려 있다.
// Entity에 매핑된 컬럼이 아니라 여기서는 선언하지 않는다.
@Entity
@Table(
name = "student_transfer_statuses",
uniqueConstraints = @UniqueConstraint(name = "uk_student_transfer_statuses_member_id", columnNames = "member_id")
indexes = @Index(name = "idx_student_transfer_statuses_member_id_id", columnList = "member_id, student_transfer_status_id")
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentTransferStatusJpaEntity extends BaseTimeEntity {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,33 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

/**
* 납부 요청은 이력으로 쌓인다. 회원의 지금 상태는 PK가 가장 큰 행이다 — 같은 회원에게 반려(REJECTED)가 여러 개
* 쌓이고 최신 행 자체가 반려일 수도 있어 상태로는 고를 수 없고, requested_at은 초 단위라 같은 초에 겹칠 수 있다.
*/
public interface StudentTransferStatusJpaRepository extends JpaRepository<StudentTransferStatusJpaEntity, Long> {

Optional<StudentTransferStatusJpaEntity> findByMemberId(Long memberId);
Optional<StudentTransferStatusJpaEntity> findFirstByMemberIdOrderByIdDesc(Long memberId);

List<StudentTransferStatusJpaEntity> findAllByMemberIdIn(List<Long> memberIds);
@Query("""
SELECT r FROM StudentTransferStatusJpaEntity r
WHERE r.memberId IN :memberIds
AND r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId)
""")
List<StudentTransferStatusJpaEntity> findLatestByMemberIdIn(@Param("memberIds") List<Long> memberIds);

// 회원별 최신 요청만 대상으로 한다. 상태 필터도 최신 요청의 상태로 건다
@Query(value = """
SELECT r FROM StudentTransferStatusJpaEntity r
WHERE (:status IS NULL OR r.status = :status)
WHERE r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId)
AND (:status IS NULL OR r.status = :status)
AND (:filterByMember = false OR r.memberId IN :memberIds)
ORDER BY r.createdAt DESC, r.id DESC
""",
countQuery = """
SELECT COUNT(r) FROM StudentTransferStatusJpaEntity r
WHERE (:status IS NULL OR r.status = :status)
WHERE r.id = (SELECT MAX(r2.id) FROM StudentTransferStatusJpaEntity r2 WHERE r2.memberId = r.memberId)
AND (:status IS NULL OR r.status = :status)
AND (:filterByMember = false OR r.memberId IN :memberIds)
""")
Page<StudentTransferStatusJpaEntity> search(
Expand Down
Loading