Repository navigation
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
tnals0924
left a comment
There was a problem hiding this comment.
PR 설명이 꼼꼼해서 스택으로 나뉘어 있던 작업을 한 번에 따라가기 편했어요. 감사합니다!
꼭 반영이 필요해 보이는 건 아래 네 가지예요. 자세한 내용은 각 줄 코멘트에 적었습니다.
- 탈퇴 회원의 요청이 페이지에 있으면 관리자 목록이 통째로 404가 나요 (
AdminFeeSearchUseCase,isStreamMember가 아직 필요한지도 여쭤봤어요) - 상태 변경 요청에
status가 없으면 400이 아니라 500이 나요 (FeeStatusUpdateRequest) - 기존 행의
requestedAt이 모두null로 내려가요 (V20) - 입력값 검증은 컨벤션(
coding-style.md3절)대로 Request record의 Bean Validation으로 하고, 도메인 정책 검증은 VO로 옮기면 좋겠어요 (FeeSubmitRequest)
리뷰 포인트 주신 내용에 대한 의견은 해당 줄에 달아 두었어요.
- 프론트 배포 순서는 말씀하신 대로 맞추면 될 것 같아요. 다만 4번을 반영하면 형식 오류의 응답 코드도
INVALID_INPUT으로 바뀌니, 프론트에 같이 공유해 주시면 좋겠습니다.
그 밖에 사소한 것 두 가지예요.
[a]3·4번 커밋이 단독으로 컴파일되지 않는다고 적어주셨는데, 저희가 실제로는 Merge commit으로 머지하고 있어서 그 커밋들이 main 이력에 그대로 남아요. 나중에git bisect를 할 때 걸릴 수 있으니, 수정하시면서 3~5번을 컴파일되는 단위로 다시 묶어주시면 좋을 것 같아요.[a].envgitignore 커밋은 회비 작업과 관계가 없어서, 따로 올리면 이력이 더 깔끔할 것 같습니다.
| membersById.get(transferStatus.getMemberId()), | ||
| // 가입 여부는 member가 아는 사실이라 member의 공개 메서드에 묻는다. | ||
| // 줄마다 한 번씩 부르므로 목록이 커지면 벌크 조회를 member 쪽에 요청할 자리다 | ||
| memberService.isSignedUp(transferStatus.getMemberId()) |
There was a problem hiding this comment.
[r] isSignedUp이 내부에서 getById를 거쳐 findByIdAndIsDeletedFalse로 조회하고 있어요. 그래서 탈퇴(소프트 삭제)한 회원이면 MEMBER_NOT_FOUND가 나요. AdminFeeSearchResponse는 member == null을 placeholder로 처리하도록 되어 있는데 이 줄에서 먼저 터지다 보니, 검색어 없이 목록을 열었을 때 탈퇴 회원의 요청이 한 건만 섞여도 페이지 전체가 404가 될 것 같아요. 행마다 회원과 약관을 다시 조회해서 한 페이지에 쿼리가 40개쯤 더 나가는 것도 있고요.
그런데 고치기 전에 isStreamMember가 지금 목록에 꼭 필요한지 여쭤보고 싶어요. #126에서는 목록이 회원 기준이라 요청하지 않은 회원, 이관만 되고 가입하지 않은 회원까지 나와서 구분할 의미가 있었던 것 같아요. 이번 PR에서는 납부를 요청한 회원만 나오는데, 요청하려면 앱에 로그인해야 하고 앱은 로그인 응답의 signUpRequired로 가입 화면부터 보여주니 거의 항상 true일 것 같아요. false가 되는 건 필수 약관이 늘어 재동의 전인 경우 정도인데, 이건 납부 확인과는 관계가 없어 보이고요.
화면에서 이 컬럼이 빠져도 된다면 필드와 이 조회를 같이 지우는 게 가장 간단하고, 위 두 문제도 함께 없어져요. 꼭 필요하다면 member 쪽에 가입을 마친 회원 ID를 한 번에 돌려주는 메서드(예: findSignedUpIds(List<Long>))를 두고 탈퇴 회원은 false로 처리해 주시면 좋겠어요. 그리고 회비 목록에서 isStreamMember는 "학생회 회원(=납부자)"으로도 읽힐 수 있어서, 남긴다면 isSignedUp처럼 판정 기준이 드러나는 이름이 나을 것 같습니다.
| @NotBlank(message = "처리 상태를 입력해 주세요.") | ||
| String status | ||
| @Schema(description = "변경할 납부 상태", example = "REJECTED") | ||
| String status, |
There was a problem hiding this comment.
[r] 기존 @NotBlank가 빠지면서 status 없이 요청이 오면 500이 나는 것 같아요. 흐름은 이래요.
TransferStatus.from(null)은 목록 필터용으로 만든 거라null을 돌려줘요.validateAdminNote는 REJECTED가 아니라서 그냥 통과해요.- 도메인
review(null, ...)가 상태를null로 바꿔요. - 저장할 때
statusNOT NULL 위반이 나는데, 유니크 제약 위반이 아니라서throw e로 그대로 나가요.
main에서는 400 INVALID_INPUT이었어요. 어노테이션을 다시 붙여주시면 좋겠습니다.
public record FeeStatusUpdateRequest(
@NotBlank(message = "처리 상태를 입력해 주세요.")
String status,
String adminNote
) {}컨트롤러에도 @Valid를 다시 붙여주시면 됩니다. 안내 문구는 상태에 따라 필수 여부가 달라져서 어노테이션보다 validate~ 메서드가 맞을 것 같아요. 둘 자리는 AdminFeeReviewUseCase 코멘트에 따로 적었어요.
There was a problem hiding this comment.
3d4c93d 수정 완료했습니다!
공지 카테고리처럼 status를 TransferStatus enum + @NotNull로 받도록 했어요. 없는 값이면 본문을 읽는 단계에서 INVALID_INPUT으로 막힙니다.
| 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; |
There was a problem hiding this comment.
[r] 예전 응답은 요청 시각으로 created_at을 썼는데, 이제는 requested_at만 읽어요(FeeResponse.java:53, AdminFeeSearchResponse.java:49). 이 컬럼이 백필 없이 NULL로 추가되다 보니 기존 행은 전부 requestedAt: null로 내려가요. 배포 시점에 처리를 기다리던 PENDING 건도 관리자 목록에서 요청일이 비어 보일 것 같아요. PR 한계에 amount·remaining_semesters는 적어주셨는데 이건 빠져 있어서 말씀드려요.
이번 PR부터는 요청마다 새 행을 쌓으니 requested_at은 항상 created_at과 같은 값이에요. 지워진 주석에도 "requested_at 컬럼을 두지 않기로 했다(ERD 기준)"고 되어 있었고요. 그때는 한 행을 재사용해서 필요했던 컬럼인데, 지금 구조에서는 오히려 없어도 될 것 같아요. 컬럼을 빼고 created_at을 쓰는 방향을 제안드려요. 남기신다면 UPDATE student_transfer_statuses SET requested_at = created_at WHERE requested_at IS NULL로 백필해 주시면 좋겠습니다.
| * 검증에 Bean Validation을 쓰지 않는다 — 명세가 항목마다 전용 에러 코드를 요구하는데 | ||
| * 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(coding-style 검증 규칙, fee-domain-study 08번 문서). | ||
| */ | ||
| public record FeeSubmitRequest( |
There was a problem hiding this comment.
[r] 입력값 검증은 coding-style.md 3절대로 Request record에 Bean Validation 어노테이션을 붙이면 좋겠어요. 항목마다 전용 코드를 주고 싶으신 마음은 이해하는데, 다른 API는 모두 INVALID_INPUT과 필드 메시지로 가고 있어서 회비만 다르면 프론트도 두 방식을 같이 다뤄야 하더라고요. 주석에 근거로 적어주신 "fee-domain-study 08번 문서"도 저장소에는 없는 문서라(docs/plans가 gitignore 대상이에요) 다른 분들이 찾아볼 수 없어요.
기준은 이렇게 나누면 어떨까요?
- 입력 형식(필수·길이·패턴·양수): Request record 어노테이션 →
INVALID_INPUT - 도메인 정책(남은 학기 1~8, 허용 은행 목록):
core:domainVO의 정적 팩토리에서 검증 →FeeErrorCode - 상태에 따라 필수 여부가 달라지는 규칙(확인필요면 안내 문구 필수): 도메인 객체 안의
validate~메서드 (AdminFeeReviewUseCase코멘트) - 다른 도메인을 조회해야 하는 검증(인증 이미지의 존재·업로더·카테고리): 지금처럼 UseCase
남은 학기 수는 예전 GradeSemester나 FeedbackPeriod.of처럼 정적 팩토리에서 검증하는 VO로 두면, 금액 계산까지 한 곳에 모을 수 있어서 FeeAmountCalculator를 지울 수 있을 것 같아요.
// core:domain:welfare — fee/domain
public record RemainingSemesters(int value) {
private static final int MIN = 1;
private static final int MAX = 8;
public static RemainingSemesters of(int value) {
validateRange(value);
return new RemainingSemesters(value);
}
public long calculateAmount(long amountPerSemester) {
return (long) value * amountPerSemester;
}
private static void validateRange(int value) {
if (value < MIN || value > MAX) {
throw new BusinessException(FeeErrorCode.INVALID_REMAINING_SEMESTERS);
}
}
}public record FeeSubmitRequest(
@NotNull(message = "남은 학기 수를 입력해 주세요.")
Integer remainingSemesters,
@NotBlank(message = "학점이수현황 캡처를 올려 주세요.")
@Size(max = 255, message = "이미지 키는 255자 이하여야 합니다.")
String proofImageKey
) {
public FeeSubmitCommand toCommand() {
return new FeeSubmitCommand(RemainingSemesters.of(remainingSemesters), proofImageKey);
}
}컨트롤러에는 @Valid @RequestBody를 붙여주시면 됩니다. @NotBlank로 막으면 FeeSubmitUseCase.validateProofImage의 null·공백 검사도 지워도 될 것 같아요.
| -- UNPAID 행 중 운영진이 처리한 적 있는 행(reviewed_at이 있음)은 돌려보낸 요청이었으므로 REJECTED로 남기고, | ||
| -- 처리된 적 없는 행(V21이 전 회원에게 만든 행, 제출만 하고 확인 요청을 안 한 행)은 지운다. | ||
| UPDATE student_transfer_statuses | ||
| SET status = 'REJECTED' |
There was a problem hiding this comment.
[c] main에서 반려된 행이 여기서 문구 없이 REJECTED가 되다 보니, 앱에서는 "확인필요"인데 안내 문구가 비어 보이고 프론트도 그 경우를 따로 처리해야 할 것 같아요. 새로 처리하는 반려는 항상 문구가 있으니, 기존 행에도 기본 문구를 채워두면 "REJECTED면 문구가 있다"가 모든 행에서 성립해요.
UPDATE student_transfer_statuses
SET status = 'REJECTED',
admin_note = '운영진이 납부를 확인하지 못했어요. 학생회에 문의해 주세요.'
WHERE status = 'UNPAID'
AND reviewed_at IS NOT NULL;문구는 학생회나 기획 쪽과 맞춰서 정하시면 될 것 같고, 마이그레이션을 합치신다면 합친 데이터 마이그레이션에 넣어주시면 됩니다.
There was a problem hiding this comment.
3c2bd38 수정 완료했습니다!
우선 제안해 주신 문구로 넣었고, 학생회와 문구를 맞춰 보고 필요하면 바꾸겠습니다.
|
|
||
| -- 최신 요청 조회용. 회원당 유니크 인덱스를 지우기 전에 먼저 만들어 member_id 조회가 인덱스를 잃지 않게 한다. | ||
| CREATE INDEX idx_student_transfer_statuses_member_id_id | ||
| ON student_transfer_statuses (member_id, student_transfer_status_id); |
There was a problem hiding this comment.
[a] InnoDB는 보조 인덱스 끝에 PK를 자동으로 붙여서 (member_id)만으로도 같은 효과가 나요(flyway-migration.md 3-4절 노트). 이름도 idx_student_transfer_statuses_member_id로 맞추고, StudentTransferStatusJpaEntity의 @Index도 같이 바꿔주시면 될 것 같아요.
| -- 낙관적 락으로는 막을 수 없다 — 두 요청 모두 기존 행을 고치지 않고 새 행을 넣기만 하고, 첫 요청은 행 자체가 없다. | ||
| ALTER TABLE student_transfer_statuses | ||
| ADD COLUMN active_member_id BIGINT | ||
| GENERATED ALWAYS AS (IF(status IN ('PENDING', 'PAID'), member_id, NULL)) VIRTUAL; |
There was a problem hiding this comment.
[a] 리뷰 포인트 주신 생성 컬럼 유니크와 "최신 요청만 처리" 조합은 좋아 보여요. 최신 요청만 바꿀 수 있으니 지난 행은 항상 REJECTED로 남고, 그래서 유니크가 "진행 중·완료는 회원당 하나"를 정확히 지켜줘요. flyway-migration.md 3-4절의 generated column 패턴, 사물함의 ConstraintViolationUtil 변환과도 결이 같아서 읽기 편했습니다.
| + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. " | ||
| + "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다. " | ||
| + "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다.") | ||
| @ApiErrorCode(type = FeeErrorCode.class, |
There was a problem hiding this comment.
[a] 사소한 문서 세 가지예요.
MEMBER_NOT_FOUND가 빠졌는데,AdminFeeReviewUseCase.java:34가 여전히memberService.getById를 불러서 탈퇴 회원의 요청을 처리하면 404가 나요. 목록에 탈퇴 회원 행이 보이니 실제로 누를 수 있는 경우라 다시 넣어주시면 좋겠어요.- 설명에 "확인필요가 아닌 상태로 바꿀 때는 안내 문구를 보내도 저장하지 않는다"를 한 줄 넣어주시면 좋겠어요. 응답의
adminNote가null로 오긴 하지만, 문서에 있으면 프론트가 헷갈리지 않을 것 같아요. @Tag이름이 "학생회비 (운영진)"으로 바뀌었는데, 다른 관리자 API는 "공지 관리" 형식이라 원래의 "학생회비 관리"로 두는 게 어떨까요?
| } catch (DataIntegrityViolationException e) { | ||
| // 같은 회원에게 진행 중·완료 요청이 이미 있다 — 동시에 들어온 두 번째 요청이 여기서 걸린다 | ||
| if (ConstraintViolationUtil.isViolated(e, ACTIVE_REQUEST_UNIQUE_CONSTRAINT)) { | ||
| throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED); |
There was a problem hiding this comment.
[a] 드문 경우라 참고로만 말씀드려요. 운영진이 최신 REJECTED 건을 PAID로 바꾸는 순간 학생이 새 요청을 넣으면 유니크가 잘 막아줘요. 그런데 운영진 쪽이 지면 학생용 문구인 "이미 납부 확인을 요청했거나 납부가 완료되었습니다"를 받게 돼요. 관리자 API 설명에 한 줄 적어두는 정도면 충분할 것 같습니다.
There was a problem hiding this comment.
a1d2ee5 수정 완료했습니다!
관리자 API 설명과 @ApiErrorCode에 같이 적어 뒀어요.
ac556eb to
abb0122
Compare
|
리뷰 반영했습니다! 꼼꼼하게 봐주셔서 감사합니다 🙏
리뷰 외에 같이 정리한 것도 있어요. |
#️⃣연관된 이슈
payers명부(PayerService.validatePayer)로 판정합니다🎯 해결하려는 문제가 무엇인가요?
학생회비 납부 기획과 화면(학생 앱·관리자 콘솔)이 바뀌었는데, 지금 API는 예전 흐름에 맞춰져 있습니다.
GET /v1/app/fee/me가grade·semester를 받아 "(8학기 − 이수 학기) × 학기당 회비"로 계산합니다. 실제 남은 학기와 어긋날 수 있고, 근거 자료도 받지 않습니다UNPAID로 되돌릴 뿐 사유를 남길 곳이 없습니다FEE_REQUEST_ALREADY_REVIEWED로 막혀 있습니다PUT /link,PUT /amount두 API로 갈려 있는데 화면은 한 폼에서 같이 저장합니다FEE_TRANSFER_LINK_INVALID)이 납니다❓ 왜 해결해야 하나요?
바뀐 납부 흐름은 남은 학기 수 입력 + 학점이수현황 캡처 → 토스 송금 → "송금 완료했어요" 입니다. 학생이 실제로 결정하는 순간은 마지막 한 번이라 그때 요청을 한 번에 받아야 하고, 운영진은 캡처와 금액을 보고 납부를 확인·반려·정정할 수 있어야 합니다.
⭐ 어떻게 해결했나요?
납부 흐름
GET /v1/app/fee/me?remainingSemesters=6이 금액과 토스 송금 링크를 계산해 줍니다. 아무것도 저장하지 않습니다POST /v1/app/fee로 남은 학기 수와 캡처 키를 보내면, 요청 시점 금액과 함께 새 요청 행이 바로 납부확인중(PENDING)으로 저장됩니다PAID)나 확인필요(REJECTED, 반려 사유 필수)로 처리합니다. 처리한 건도 다시 바꿀 수 있습니다상태와 저장 (
student_transfer_statuses)PENDING(납부확인중) /PAID(납부완료) /REJECTED(화면 표기 "확인필요")입니다. 한 번도 요청하지 않은 회원은 행이 없고, 앱 응답에서만feeStatus: "UNPAID"로 내려갑니다remaining_semesters,amount(요청 시점 금액),proof_image_key,reject_reason. 요청 시각은 행이 생긴 시각(created_at)으로 갈음합니다created_at은 초 단위라 겹칠 수 있어 PK로 고릅니다REJECTED일 때만 받습니다. 그 외에는 409FEE_REQUEST_ALREADY_REQUESTED입니다active_member_id = IF(status IN ('PENDING','PAID'), member_id, NULL)생성 컬럼에 UNIQUE를 겁니다. 진행 중·완료 요청은 회원당 하나만 들어가고 반려 행은 몇 개든 남습니다. 두 번째 INSERT의 제약 위반은saveAndFlush+ConstraintViolationUtil로 409로 바꿉니다(사물함 신청과 같은 패턴)FEE_REQUEST_NOT_LATEST입니다도메인 객체 (
fee/domain)RemainingSemesters: 남은 학기 수(1~8) 검증과 금액 계산(남은 학기 수 × 학기당 회비)FeeAccount: 납부 계좌·학기당 회비와 허용 은행 22개. 운영진이 저장할 때(create)만 은행을 검증하고, 저장된 설정을 읽을 때(of)는 복원만 합니다(예전PUT /link가 은행 목록을 검사하지 않아서입니다)TransferGuide: 학생에게 지금 안내할 남은 학기 수·금액·송금 링크. 조회할 때마다 계산하고 저장하지 않습니다FeeSubmitCommand: 납부 요청(남은 학기 수 + 캡처 키)StudentTransferStatus: 다시 낼 수 있는지(isRequestable), 납부 확인 시각(getConfirmedAt), 반려 사유 검증(확인필요면 필수·255자 이하, 앞뒤 공백 제거)을 스스로 판단합니다사용자 API (
/v1/app/fee)GET /megrade·semester로 계산한 송금 링크와 상태remainingSemesters(선택). 계좌·상태를 주고, 납부 전·확인필요인 학생이 학기 수를 보내면 금액·링크를 계산합니다. 납부확인중·납부완료면 요청 때 남긴 금액만 주고 링크는 주지 않습니다POST /grade·semester→ 납부확인중{remainingSemesters, proofImageKey}→ 새 요청을 납부확인중으로 저장. 응답은GET /me와 같은FeeResponseFeeStatusUseCase, 요청은FeeSubmitUseCase가 맡고 컨트롤러는 UseCase만 부릅니다FeeResponse:studentTransferStatusId,feeStatus,bank,accountNo,remainingSemesters,transferAmount,transferLinkUrl,requestedAt,confirmedAt(납부완료일 때),rejectReason(확인필요일 때)FEE_TRANSCRIPT_IMAGE카테고리인지까지 봅니다. 이를 위해FileCategory.FEE_TRANSCRIPT_IMAGE와FileService.findByFileKey(+files.file_keyprefix 인덱스)를 추가했습니다관리자 API (
/v1/admin/fee)GET /requestsisSignedUp(가입 완료 여부),remainingSemesters,transferAmount(요청 시점 값),proofImageUrl,confirmedAt,rejectReason을 추가하고status→feeStatus로 바꿨습니다. 탈퇴 회원의 요청도"(탈퇴한 회원)"으로 보입니다PATCH /requests/{studentTransferStatusId}{status, rejectReason}. 처리한 건도 다시 바꿀 수 있고PAID도 종결 상태가 아닙니다.REJECTED는 반려 사유(255자 이하)가 필수이고, 다른 상태면 사유를 보내도 저장하지 않습니다. 최신 요청만 바꿀 수 있습니다GET /accountnull, 회비는 기본값입니다PUT /accountPUT /link와PUT /amount를 대체합니다. 은행은 허용 목록 22개, 계좌번호는 숫자 10~14자리, 회비는 1 이상입니다findSignedUpIds로 한 페이지의 회원을 한 번에 조회합니다(회원마다 조회하지 않음)payers명부 반영은 지금처럼 한 트랜잭션입니다입력 검증과 에러 코드
@ValidINVALID_INPUTRemainingSemesters,FeeAccountINVALID_REMAINING_SEMESTERS,INVALID_BANKStudentTransferStatus.reviewINVALID_REJECT_REASONFeeSubmitUseCaseINVALID_PROOF_IMAGEFEE_ACCOUNT_NOT_FOUND(404),FEE_REQUEST_ALREADY_REQUESTED·FEE_REQUEST_NOT_LATEST(409)FEE_REQUEST_ALREADY_REVIEWED,FEE_TRANSFER_LINK_INVALID(500 → 계좌 미설정은FEE_ACCOUNT_NOT_FOUND404)마이그레이션 (V23~V25)
V23__add_file_key_index_to_filesfiles.file_keyprefix(255) 인덱스V24__add_request_columns_and_active_unique_to_student_transfer_statusesstatus의DEFAULT 'UNPAID'제거,(member_id)인덱스, 회원당 유니크 → 진행 중 요청 유니크(생성 컬럼)V25__convert_unpaid_transfer_statuses_to_rejectedUNPAID정리. 운영진이 처리한 적 있는 행(main에서 반려된 행)은REJECTED+ 기본 반려 사유, 나머지는 삭제main 데이터 기준 순효과:
PENDING·PAID행은 그대로이고, 반려돼UNPAID가 된 행은REJECTED(기본 반려 사유)가 됩니다. MySQL 8.4 컨테이너에 main 상태 샘플 데이터를 넣고 적용해 결과를 확인했습니다.커밋 구성
모든 커밋은 하나씩 체크아웃해서
./gradlew check가 통과하는 것을 확인했습니다.🧩 이 PR의 한계 & 트레이드오프
members.is_fee_paid는 만들지 않기로 했습니다. 납부 여부는 지금처럼payers명부로 판정하고, 조회 부담이 생기면 나중에 Redis 캐싱을 검토합니다RentalApplyUseCase가PayerService.validatePayer로 확인합니다. 아래 두 곳도 신청할 때 회비 납부를 확인해야 하는데, 이 PR에는 없습니다target은 자유 문구)PayerService.validatePayer를 조합해야 합니다(지금은 컨트롤러가 서비스를 직접 부릅니다)amount·remaining_semesters가 비어 있어 금액이null로 보입니다./gradlew check(ModularityTests·DomainImplAccessTests, 서버 전체 컨텍스트를 띄우는RentalApplyConcurrencyTest— MySQL 컨테이너에 FlywayV25까지 적용,ddl-auto=validate). R2 환경변수는 연결하지 않는 더미 값입니다. 회비 쪽 단위 테스트와 동시 요청 409 변환 테스트는 없습니다⛓️ 기존 기능에 미치는 영향
GET /me파라미터(grade·semester→remainingSemesters)와 응답,POST /요청·응답PUT /link·PUT /amount제거 →PUT /account,GET /account신규, 목록·처리 응답 필드 변경, 상태 문자열UNPAID는 400이고REJECTED가 새로 생깁니다INVALID_INPUT+ 필드 메시지로 내려갑니다student_transfer_statuses에 같은 회원의 행이 여러 개 생깁니다. 이 테이블을 회원 기준으로 읽는 곳은 모두 최신 요청 기준으로 바꿨습니다.payers는 그대로 회원당 1행입니다findSignedUpIds(가입 완료 회원 ID 일괄 조회)가 추가됩니다. 기존isSignedUp과 같은 기준입니다flyway clean이 필요합니다🔀 Edge Case & 실패 시나리오
GET /me·POST /FEE_ACCOUNT_NOT_FOUND(저장 전에 막음)INVALID_REMAINING_SEMESTERSINVALID_INPUTINVALID_PROOF_IMAGEPENDING·PAID인데 다시POSTFEE_REQUEST_ALREADY_REQUESTEDREJECTED2행 +PENDING1행. 화면·목록은 최신 행PATCHFEE_REQUEST_NOT_LATESTREJECTED로 바꾸며 반려 사유가 없거나 255자 초과INVALID_REJECT_REASON"(탈퇴한 회원)"·isSignedUp: false로 정상 표시FEE_REQUEST_ALREADY_REQUESTEDpayers동기화 실패📋 검토한 대안과 선택 이유
💬 리뷰 포인트
[r]앱·관리자 API 계약 변경 — 프론트 배포 순서[r]#140을 먼저 머지해야 마이그레이션 번호가 맞습니다[c]memberfindSignedUpIds추가 — 기존hasAgreedRequiredTerms와 같은 판정을 쓰도록 공통 부분만 묶었습니다