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 @@ -24,10 +24,10 @@
@Tag(name = "학생회비 (운영진)", description = "학생회비 납부 관리")
public interface AdminFeeApi {

/** 납부자 목록. 회원 기준이라 납부 정보를 내지 않은 "납부 전" 회원도 포함된다. */
/** 납부 요청 목록. 납부를 요청한 회원만 나온다. */
@Operation(summary = "학생회비 납부자 목록 조회",
description = "이름·학번 검색과 납부 상태 필터로 회원을 조회한다. 납부 정보를 내지 않은 회원도 "
+ "납부 전으로 함께 나오며, 그런 행은 studentTransferStatusId를 비롯한 납부 값이 모두 null이다.")
description = "이름·학번 검색과 납부 상태 필터로 납부를 요청한 회원을 조회한다. "
+ "한 번도 요청하지 않은 회원은 나오지 않는다. 금액은 요청 시점에 학생에게 안내한 값이다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_TRANSFER_STATUS"})
ApiResponse<PageResponse<AdminFeeSearchResponse>> getRequests(
Expand All @@ -41,7 +41,7 @@ ApiResponse<PageResponse<AdminFeeSearchResponse>> getRequests(
@Operation(summary = "학생회비 납부 상태 수정",
description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. "
+ "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. "
+ "확인필요 학생은 직접 재제출할 수 없으므로, 다시 내게 하려면 납부 전(UNPAID)으로 되돌린다.")
+ "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다.")
@ApiErrorCode(type = FeeErrorCode.class,
codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND"})
ApiResponse<FeeStatusUpdateResponse> updateStatus(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee;

import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator;
import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator;

/**
Expand All @@ -24,9 +23,4 @@ public boolean isConfigured() {
public String generateTransferLinkUrl() {
return isConfigured() ? TossTransferLinkGenerator.generate(bank, accountNo, null) : null;
}

/** 남은 학기 수를 낸 학생의 납부 금액. 아직 내지 않았으면 null이다. */
public Long calculateTransferAmount(Integer remainingSemesters) {
return remainingSemesters == null ? null : FeeAmountCalculator.calculate(remainingSemesters, amount);
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee.response;

import java.time.LocalDateTime;
import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount;
import kr.ac.kookmin.stream.api.common.ApiConstants;
import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;

/**
* 납부자 목록의 한 줄. 모든 회원이 납부 상태 행을 가지므로 아직 아무것도 내지 않은 회원도 들어온다 —
* 그런 줄은 상태가 납부 전이고 제출 관련 값이 모두 null이다.
* 납부자 목록의 한 줄. 납부를 요청한 회원만 들어온다 — 한 번도 요청하지 않은 회원은 행이 없다.
*/
public record AdminFeeSearchResponse(
Long studentTransferStatusId,
Expand All @@ -34,8 +32,7 @@ public record AdminFeeSearchResponse(
public static AdminFeeSearchResponse of(
StudentTransferStatus transferStatus,
Member member,
boolean signedUp,
FeeAccount account
boolean signedUp
) {
return new AdminFeeSearchResponse(
transferStatus.getId(),
Expand All @@ -46,7 +43,8 @@ public static AdminFeeSearchResponse of(
signedUp,
transferStatus.getStatus().name(),
transferStatus.getRemainingSemesters(),
account.calculateTransferAmount(transferStatus.getRemainingSemesters()),
// 요청 시점에 남긴 금액이다. 운영진이 학기당 회비를 바꿔도 이미 들어온 요청의 금액은 그대로 보인다
transferStatus.getAmount(),
StorageUrlBuilder.build(transferStatus.getProofImageKey()),
transferStatus.getRequestedAt(),
// 납부 확인 시각은 납부완료일 때의 처리 시각이다
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

import java.util.List;
import java.util.Map;
import kr.ac.kookmin.stream.api.admin.welfare.fee.AdminFeeAccountProvider;
import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
Expand All @@ -18,9 +16,8 @@
/**
* 납부자 목록. {@code welfare}와 {@code member} 두 도메인을 조합한다.
* <p>
* 페이징과 상태 필터는 <b>회비 쪽에서</b> 한다. 모든 회원이 납부 상태 행을 갖기 때문에(로그인 때 생성,
* {@code V21}로 기존 회원 백필) 납부 전 회원도 그대로 걸린다. 회원 기준으로 뒤집으면 member 도메인이
* 회비 상태를 알아야 하는데, 회비 때문에 남의 도메인을 고치지 않으려고 방향을 이렇게 잡았다.
* 페이징과 상태 필터는 <b>회비 쪽에서</b> 한다. 납부를 요청한 회원만 행이 있으므로 목록도 요청한 회원만 나온다
* (한 번도 요청하지 않은 미납부 회원은 관리자 화면에서 다루지 않는다).
* <p>
* member 쪽은 이미 공개된 조회 메서드만 쓴다 — 검색은 {@code searchIdsByKeyword}, 회원 정보는 {@code getMapByIds}.
* <p>
Expand All @@ -32,7 +29,6 @@ public class AdminFeeSearchUseCase {

private final StudentTransferStatusService studentTransferStatusService;
private final MemberService memberService;
private final AdminFeeAccountProvider adminFeeAccountProvider;

public PageResult<AdminFeeSearchResponse> search(TransferStatus status, String keyword, PageOffset pageOffset) {
List<Long> memberIds = null;
Expand All @@ -48,16 +44,13 @@ public PageResult<AdminFeeSearchResponse> search(TransferStatus status, String k

// 탈퇴(소프트 삭제)한 회원은 빠지므로 그 회원의 행은 member가 null로 남는다
Map<Long, Member> membersById = memberService.getMapByIds(pagedMemberIds);
// 학기당 금액은 줄마다 같으므로 목록당 한 번만 읽는다
FeeAccount account = adminFeeAccountProvider.getAccount();

return page.map(transferStatus -> AdminFeeSearchResponse.of(
transferStatus,
membersById.get(transferStatus.getMemberId()),
// 가입 여부는 member가 아는 사실이라 member의 공개 메서드에 묻는다.
// 줄마다 한 번씩 부르므로 목록이 커지면 벌크 조회를 member 쪽에 요청할 자리다
memberService.isSignedUp(transferStatus.getMemberId()),
account
memberService.isSignedUp(transferStatus.getMemberId())
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand;
import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
import kr.ac.kookmin.stream.security.AuthTokenIssuer;
import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

Expand All @@ -25,16 +24,11 @@ public class OAuthLoginUseCase {
private final OAuthService oauthService;
private final MemberService memberService;
private final AuthTokenIssuer authTokenIssuer;
private final StudentTransferStatusService studentTransferStatusService;

public OAuthLoginResponse login(OAuthLoginCommand command) {
OAuthUserInfo userInfo = oauthService.authenticate(command);
Member member = findOrRegister(userInfo);

// 모든 회원이 납부 상태 행을 갖게 해서, 관리자 납부자 목록이 납부 전 회원까지 보여줄 수 있게 한다.
// 회비가 자기 테이블에서 전원을 다루므로 member 도메인은 회비를 몰라도 된다.
studentTransferStatusService.createIfAbsent(member.getId());

String accessToken = authTokenIssuer.issue(member.getId(), member.getRole(), member.getCouncilDepartment());
// 회원을 이번에 만들었는지가 아니라 저장된 전화번호·동의 기록으로 판단한다. 회원가입 화면에서 이탈한 회원도 다시 가입 화면을 본다
boolean signUpRequired = !memberService.isSignedUp(member.getId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,39 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.ac.kookmin.stream.api.app.AppApiUser;
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.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>
* 납부는 두 단계다. 납부 정보를 제출해도 상태는 <b>납부 전</b>에 머물고, 토스로 송금한 뒤
* 송금 확인을 요청해야 <b>납부확인중</b>이 된다. 제출만 하고 이탈한 학생을 운영진이
* 처리 대기로 보지 않기 위해서다.
* 납부는 한 번에 요청한다. 학생은 남은 학기 수로 송금 링크를 받아(조회만, 저장하지 않음) 토스에서 송금한 뒤,
* 학점이수현황 캡처와 함께 납부를 요청한다. 이때 처음 저장되고 바로 <b>납부확인중</b>이 된다.
* 운영진이 확인필요로 돌려보내면 학생은 학기 인증부터 다시 낼 수 있다.
* <p>
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다.
*/
@Tag(name = "학생회비", description = "학생 앱 학생회비 납부")
public interface AppFeeApi {

/** 내 학생회비 납부 현황. 화면은 feeStatus와 studentTransferStatusId로 분기한다. */
/** 내 학생회비 납부 현황. 남은 학기 수를 주면 그 값으로 금액과 송금 링크를 계산한다. */
@Operation(summary = "내 학생회비 상태 조회",
description = "로그인한 학생의 납부 상태와 납부 계좌, 납부 금액과 송금 링크를 조회한다. "
+ "납부 정보를 제출하기 전이거나 확인필요 상태면 금액과 송금 링크는 내려가지 않는다.")
@ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_ACCOUNT_NOT_FOUND"})
ApiResponse<FeeResponse> getFeeByMemberId(AppApiUser apiUser);
description = "로그인한 학생의 납부 상태와 납부 계좌를 조회한다. 아무것도 저장하지 않는다. "
+ "납부 전(UNPAID)이거나 확인필요(REJECTED)인 학생이 remainingSemesters(남은 학기 수, 1~8)를 보내면 "
+ "그 값으로 납부 금액과 송금 링크를 계산해 준다. 납부확인중·납부완료면 요청할 때 남긴 금액만 보여주고 "
+ "송금 링크는 주지 않는다.")
@ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_REMAINING_SEMESTERS", "FEE_ACCOUNT_NOT_FOUND"})
ApiResponse<FeeResponse> getFeeByMemberId(AppApiUser apiUser, Integer remainingSemesters);

/** 납부 정보 제출. 상태는 납부 전 그대로이고, 돌려받은 송금 링크로 토스에서 송금한다. */
@Operation(summary = "학생회비 납부 정보 제출",
description = "남은 학기 수와 학점이수현황 캡처를 제출하고, 납부 금액이 붙은 토스 송금 링크를 받는다. "
+ "상태는 납부 전 그대로이며 이전에 제출한 값과 이미지는 덮어쓴다. "
+ "확인필요로 처리된 학생은 직접 다시 제출할 수 없고 운영진이 납부 전으로 되돌려야 한다.")
/** 학생회비 납부 요청. 학기 인증과 송금 완료를 한 번에 받아 바로 납부확인중이 된다. */
@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 = FeeErrorCode.class,
codes = {"FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_ALREADY_REQUESTED"})
ApiResponse<FeeConfirmResponse> requestConfirmation(AppApiUser apiUser);
ApiResponse<FeeResponse> submit(AppApiUser apiUser, FeeSubmitRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,17 @@

import kr.ac.kookmin.stream.api.app.AppApiUser;
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.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.FeeAmountCalculator;
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 @@ -25,24 +24,27 @@ public class AppFeeController implements AppFeeApi {
private final FeeAccountProvider feeAccountProvider;
private final FeeSubmitUseCase feeSubmitUseCase;

// 남은 학기 수는 금액과 송금 링크를 계산할 때만 쓴다. 이 조회는 아무것도 저장하지 않는다
@Override
@GetMapping("/me")
public ApiResponse<FeeResponse> getFeeByMemberId(AppApiUser apiUser) {
StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId());
public ApiResponse<FeeResponse> getFeeByMemberId(
AppApiUser apiUser,
@RequestParam(required = false) Integer remainingSemesters
) {
Integer semesters = remainingSemesters == null
? null
: FeeAmountCalculator.validateRemainingSemesters(remainingSemesters);
FeeAccount account = feeAccountProvider.getAccount();

return ApiResponse.success(FeeResponse.of(transferStatus, feeAccountProvider.getAccount()));
FeeResponse response = studentTransferStatusService.findByMemberId(apiUser.userId())
.map(transferStatus -> FeeResponse.of(transferStatus, account, semesters))
.orElseGet(() -> FeeResponse.notRequested(account, semesters));
return ApiResponse.success(response);
}

@Override
@PostMapping
public ApiResponse<FeeSubmitResponse> submit(AppApiUser apiUser, @RequestBody FeeSubmitRequest request) {
public ApiResponse<FeeResponse> submit(AppApiUser apiUser, @RequestBody FeeSubmitRequest request) {
return ApiResponse.success(feeSubmitUseCase.submit(apiUser.userId(), request));
}

@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
Expand Up @@ -3,7 +3,7 @@
import io.swagger.v3.oas.annotations.media.Schema;

/**
* 납부 정보 제출. 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 받는다.
* 학생회비 납부 요청. 송금을 마친 뒤 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 함께 보낸다.
* <p>
* 검증에 Bean Validation을 쓰지 않는다 — 명세가 항목마다 전용 에러 코드를 요구하는데
* 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(coding-style 검증 규칙, fee-domain-study 08번 문서).
Expand Down

This file was deleted.

Loading