Skip to content
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ docs/plans

# Firebase 서비스 계정 키 (env로 주입하고 파일은 커밋하지 않는다)
*firebase-adminsdk*.json

# 로컬 환경변수. .env.example만 저장소에 둔다
.env
.env.local
Original file line number Diff line number Diff line change
Expand Up @@ -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}가 맡는다.
* <p>
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
* 파라미터 바인딩(@{@code RequestParam}, @{@code RequestBody} 등)은 구현체에 둔다.
* 납부는 두 단계다. 납부 정보를 제출해도 상태는 <b>납부 전</b>에 머물고, 토스로 송금한 뒤
* 송금 확인을 요청해야 <b>납부확인중</b>이 된다. 제출만 하고 이탈한 학생을 운영진이
* 처리 대기로 보지 않기 위해서다.
* <p>
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다.
*/
@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<FeeResponse> getFeeByMemberId(
AppApiUser apiUser,
Integer grade,
Integer semester
);
description = "로그인한 학생의 납부 상태와 납부 계좌, 납부 금액과 송금 링크를 조회한다. "
+ "납부 정보를 제출하기 전이거나 확인필요 상태면 금액과 송금 링크는 내려가지 않는다.")
@ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_ACCOUNT_NOT_FOUND"})
ApiResponse<FeeResponse> 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<FeeSubmitResponse> 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<FeeResponse> requestConfirmation(
AppApiUser apiUser,
FeeConfirmRequest request
);
/** 송금 확인 요청. 여기서 비로소 납부확인중으로 바뀐다. */
@Operation(summary = "학생회비 송금 확인 요청",
description = "송금을 마친 학생이 운영진의 확인을 요청한다. 납부 정보를 제출한 학생만 요청할 수 있다.")
@ApiErrorCode(type = FeeErrorCode.class,
codes = {"FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_ALREADY_REQUESTED"})
ApiResponse<FeeConfirmResponse> requestConfirmation(AppApiUser apiUser);
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
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.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.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
Expand All @@ -22,29 +23,26 @@ public class AppFeeController implements AppFeeApi {

private final StudentTransferStatusService studentTransferStatusService;
private final FeeTransferUrlProvider feeTransferUrlProvider;
private final FeeSubmitUseCase feeSubmitUseCase;

@Override
@GetMapping("/me")
public ApiResponse<FeeResponse> getFeeByMemberId(
AppApiUser apiUser,
@RequestParam(required = false) Integer grade,
@RequestParam(required = false) Integer semester
) {
GradeSemester gradeSemester = GradeSemester.of(grade, semester);
public ApiResponse<FeeResponse> 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()));
}

@Override
@PostMapping
public ApiResponse<FeeResponse> requestConfirmation(
AppApiUser apiUser,
@RequestBody FeeConfirmRequest request
) {
GradeSemester gradeSemester = request.toGradeSemester();
StudentTransferStatus transferStatus = studentTransferStatusService.requestConfirmation(apiUser.userId());
public ApiResponse<FeeSubmitResponse> submit(AppApiUser apiUser, @RequestBody FeeSubmitRequest request) {
return ApiResponse.success(feeSubmitUseCase.submit(apiUser.userId(), request));
}

return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getTransferUrl(gradeSemester)));
@Override
@PostMapping("/requests/confirm")
public ApiResponse<FeeConfirmResponse> requestConfirmation(AppApiUser apiUser) {
return ApiResponse.success(
FeeConfirmResponse.from(studentTransferStatusService.requestConfirmation(apiUser.userId())));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,35 @@
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;

/**
* 회비 송금 링크 생성을 담당하는 파사드.
* 회비 납부 설정을 읽어 온다.
* <p>
* 계좌(은행/계좌번호)와 학기당 금액을 조회 시점에 조합해 링크를 만든다 — 계좌만 바뀌거나 금액만 바뀌어도
* 항상 최신 값을 반영하기 위함(둘 다 admin_config_values에서 따로 관리, FeeConfigKeys 참고).
* 실제 송금액은 남은 학기 수만큼만 걷는다(FeeAmountCalculator).
* 계좌와 학기당 금액을 조회 시점에 함께 읽는다 — 계좌만 바뀌거나 금액만 바뀌어도 항상 최신 값을
* 반영하기 위함(둘 다 admin_config_values에서 따로 관리, FeeConfigKeys 참고).
* welfare 도메인은 internal 모듈에 의존할 수 없어 이 조회를 api에 둔다.
*/
@Component
@RequiredArgsConstructor
public class FeeTransferUrlProvider {

private final AdminConfigValueService adminConfigValueService;

// 계좌가 아직 설정되지 않았으면 링크를 만들 수 없으므로 예외를 던진다.
public String getTransferUrl(GradeSemester gradeSemester) {
/** 계좌가 설정되지 않았으면 송금 자체가 불가능하므로 막는다. */
public FeeAccount getAccount() {
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);
}

long amountPerSemester = adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT)
.map(Long::parseLong)
.orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT);

long amount = FeeAmountCalculator.calculate(gradeSemester, amountPerSemester);

return TossTransferLinkGenerator.generate(bank, accountNo, amount);
return new FeeAccount(bank, accountNo, amountPerSemester);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package kr.ac.kookmin.stream.api.app.welfare.fee.request;

import io.swagger.v3.oas.annotations.media.Schema;

/**
* 납부 정보 제출. 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 받는다.
* <p>
* 검증에 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
) {
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,49 @@
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) {
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(),
account.bank(),
account.accountNo(),
remainingSemesters,
transferAmount,
transferAmount == null ? null : account.transferLinkFor(transferAmount),
transferStatus.getRequestedAt(),
// 납부 확인 시각은 PAID일 때의 처리 시각이다. 그 외 상태에서는 보여주지 않는다
transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null,
transferStatus.getStatus() == TransferStatus.NEEDS_CHECK ? transferStatus.getAdminNote() : null
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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) {
long transferAmount = account.transferAmountFor(remainingSemesters);

return new FeeSubmitResponse(
account.bank(), account.accountNo(), remainingSemesters,
transferAmount, account.transferLinkFor(transferAmount));
}
}
Loading