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
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
@@ -0,0 +1,41 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee;

import java.util.Map;
import kr.ac.kookmin.stream.internal.domain.config.service.AdminConfigValueService;
import kr.ac.kookmin.stream.welfare.WelfareConstants;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
* 회비 납부 설정을 읽고 쓴다. welfare 도메인은 internal 모듈에 의존할 수 없어 이 조합을 api에 둔다.
* <p>
* 학생 쪽과 달리 계좌가 없어도 예외를 던지지 않는다 — 관리자 화면은 "아직 설정하지 않음"을 보여주고
* 그 자리에서 설정하게 해야 하기 때문이다.
*/
@Component
@RequiredArgsConstructor
public class AdminFeeAccountProvider {

private final AdminConfigValueService adminConfigValueService;

public FeeAccount getAccount() {
return new FeeAccount(
adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null),
adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null),
adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT)
.map(Long::parseLong)
.orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT)
);
}

// 계좌와 금액은 한 화면에서 같이 저장하므로 세 키를 한 번에 쓴다
public FeeAccount save(String bank, String accountNo, long amount) {
adminConfigValueService.upsertValues(Map.of(
FeeConfigKeys.TRANSFER_BANK, bank,
FeeConfigKeys.TRANSFER_ACCOUNT_NO, accountNo,
FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(amount)
));
return new FeeAccount(bank, accountNo, amount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,32 @@

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.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAmountUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeLinkUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAccountUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeStatusUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAmountUpdateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeLinkUpdateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAccountResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeStatusUpdateResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
import kr.ac.kookmin.stream.common.CommonErrorCode;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
* 운영진 학생회비 API의 문서 명세. 구현은 {@link AdminFeeController}가 맡는다.
* <p>
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
* 파라미터 바인딩(@{@code ModelAttribute}, @{@code RequestBody} 등), 부서 권한(@{@code RequireDepartment})은
* 구현체에 둔다.
* 계좌와 학기당 회비는 한 화면에서 같이 저장하므로 하나의 엔드포인트(`/account`)로 묶여 있다.
*/
@Tag(name = "학생회비 관리", description = "운영진 학생회비 납부 확인·설정")
@Tag(name = "학생회비 (운영진)", description = "학생회비 납부 관리")
public interface AdminFeeApi {

/** 납부 확인 요청 목록. 상태·키워드로 필터링하고 페이지네이션한다. */
@Operation(summary = "납부 확인 요청 목록 조회",
description = "학생회비 납부 확인 요청을 상태(status)와 이름·학번 키워드(keyword)로 필터링해 페이지 단위로 조회한다.")
/** 납부 요청 목록. 납부를 요청한 회원만 나온다. */
@Operation(summary = "학생회비 납부자 목록 조회",
description = "이름·학번 검색과 납부 상태 필터로 납부를 요청한 회원을 조회한다. "
+ "한 번도 요청하지 않은 회원은 나오지 않는다. 금액은 요청 시점에 학생에게 안내한 값이다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_TRANSFER_STATUS"})
ApiResponse<PageResponse<AdminFeeSearchResponse>> getRequests(
Expand All @@ -41,36 +37,31 @@ ApiResponse<PageResponse<AdminFeeSearchResponse>> getRequests(
@ParameterObject PageParams pageParams
);

/** 납부 확인 요청 처리. 승인/반려로 상태를 바꾸고 납부자 명부에 반영한다. */
@Operation(summary = "납부 확인 요청 처리",
description = "납부 확인 요청을 승인 또는 반려 처리하고 납부자 명부를 갱신한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(
type = FeeErrorCode.class,
codes = {"INVALID_TRANSFER_STATUS", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_ALREADY_REVIEWED"}
)
@ApiErrorCode(type = MemberErrorCode.class, codes = {"MEMBER_NOT_FOUND"})
/** 납부 상태 처리. 이미 처리한 요청도 다시 바꿀 수 있다. */
@Operation(summary = "학생회비 납부 상태 수정",
description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. "
+ "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. "
+ "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다. "
+ "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다.")
@ApiErrorCode(type = FeeErrorCode.class,
codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_NOT_LATEST"})
ApiResponse<FeeStatusUpdateResponse> updateStatus(
AdminApiUser apiUser,
Long transferStatusId,
Long studentTransferStatusId,
FeeStatusUpdateRequest request
);

/** 송금 링크(은행·계좌번호) 설정. */
@Operation(summary = "송금 링크 설정",
description = "학생회비 송금에 쓰이는 은행과 계좌번호를 설정한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
ApiResponse<FeeLinkUpdateResponse> updateLink(
AdminApiUser apiUser,
FeeLinkUpdateRequest request
);
/** 회비 납부 설정 조회. 수정 폼의 초기값으로 쓴다. */
@Operation(summary = "회비 납부 계좌 조회",
description = "설정된 은행·계좌번호·학기당 회비를 조회한다. 아직 설정하지 않았으면 은행과 계좌번호는 null이고 "
+ "학기당 회비는 기본값이 내려간다.")
ApiResponse<FeeAccountResponse> getAccount(AdminApiUser apiUser);

/** 학생회비 금액 설정. */
@Operation(summary = "학생회비 금액 설정",
description = "학생회비 송금 금액을 설정한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
ApiResponse<FeeAmountUpdateResponse> updateAmount(
AdminApiUser apiUser,
FeeAmountUpdateRequest request
);
/** 회비 납부 설정 변경. 계좌와 학기당 회비를 함께 받는다. */
@Operation(summary = "회비 납부 계좌 변경",
description = "은행·계좌번호·학기당 회비를 한 번에 저장한다. 은행은 허용 목록 중 하나여야 하고 "
+ "계좌번호는 하이픈을 제외한 숫자 10~14자리다. 송금 링크는 저장된 값으로 조회 시점에 만들어진다.")
@ApiErrorCode(type = FeeErrorCode.class,
codes = {"INVALID_BANK", "INVALID_ACCOUNT_NO", "INVALID_TRANSFER_AMOUNT"})
ApiResponse<FeeAccountResponse> updateAccount(AdminApiUser apiUser, FeeAccountUpdateRequest request);
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,20 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee;

import jakarta.validation.Valid;
import java.util.Map;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.security.RequireDepartment;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAmountUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeLinkUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAccountUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeStatusUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAmountUpdateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeLinkUpdateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAccountResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeStatusUpdateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.AdminFeeReviewUseCase;
import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.AdminFeeSearchUseCase;
import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.MemberFeeStatus;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.common.CouncilDepartment;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.internal.domain.config.service.AdminConfigValueService;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;
import lombok.RequiredArgsConstructor;
Expand All @@ -39,9 +33,9 @@
@RequiredArgsConstructor
public class AdminFeeController implements AdminFeeApi {

private final AdminConfigValueService adminConfigValueService;
private final AdminFeeSearchUseCase adminFeeSearchUseCase;
private final AdminFeeReviewUseCase adminFeeReviewUseCase;
private final AdminFeeAccountProvider adminFeeAccountProvider;

@Override
@GetMapping("/requests")
Expand All @@ -52,51 +46,43 @@ public ApiResponse<PageResponse<AdminFeeSearchResponse>> getRequests(
@RequestParam(required = false) String keyword,
@Valid @ModelAttribute PageParams pageParams
) {
TransferStatus transferStatus = TransferStatus.from(status);
PageResult<MemberFeeStatus> result = adminFeeSearchUseCase.search(transferStatus, keyword, pageParams.toOffset());
PageResult<AdminFeeSearchResponse> result = adminFeeSearchUseCase.search(
TransferStatus.from(status), keyword, pageParams.toOffset());

return ApiResponse.success(
PageResponse.from(result, (vo) -> AdminFeeSearchResponse.of(vo.status(), vo.member()))
);
return ApiResponse.success(PageResponse.from(result, response -> response));
}

@Override
@PatchMapping("/requests/{transferStatusId}")
@PatchMapping("/requests/{studentTransferStatusId}")
@RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS)
public ApiResponse<FeeStatusUpdateResponse> updateStatus(
AdminApiUser apiUser,
@PathVariable Long transferStatusId,
@Valid @RequestBody FeeStatusUpdateRequest request
@PathVariable Long studentTransferStatusId,
@RequestBody FeeStatusUpdateRequest request
) {
TransferStatus status = TransferStatus.from(request.status());
StudentTransferStatus reviewed = adminFeeReviewUseCase.review(transferStatusId, status);
StudentTransferStatus reviewed = adminFeeReviewUseCase.review(
studentTransferStatusId, TransferStatus.from(request.status()), request.adminNote());

return ApiResponse.success(FeeStatusUpdateResponse.from(reviewed));
}

@Override
@PutMapping("/link")
@GetMapping("/account")
@RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS)
public ApiResponse<FeeLinkUpdateResponse> updateLink(
AdminApiUser apiUser,
@Valid @RequestBody FeeLinkUpdateRequest request
) {
adminConfigValueService.upsertValues(Map.of(
FeeConfigKeys.TRANSFER_BANK, request.bank(),
FeeConfigKeys.TRANSFER_ACCOUNT_NO, request.accountNo()
));

return ApiResponse.success(FeeLinkUpdateResponse.of(request.bank(), request.accountNo()));
public ApiResponse<FeeAccountResponse> getAccount(AdminApiUser apiUser) {
return ApiResponse.success(FeeAccountResponse.from(adminFeeAccountProvider.getAccount()));
}

@Override
@PutMapping("/amount")
@PutMapping("/account")
@RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS)
public ApiResponse<FeeAmountUpdateResponse> updateAmount(
public ApiResponse<FeeAccountResponse> updateAccount(
AdminApiUser apiUser,
@Valid @RequestBody FeeAmountUpdateRequest request
@RequestBody FeeAccountUpdateRequest request
) {
adminConfigValueService.upsertValue(FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(request.amount()));
return ApiResponse.success(FeeAmountUpdateResponse.of(request.amount()));
FeeAccountValidator.validate(request);
FeeAccount saved = adminFeeAccountProvider.save(request.bank(), request.accountNo(), request.amount());

return ApiResponse.success(FeeAccountResponse.from(saved));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee;

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

/**
* 관리자가 설정한 회비 납부 설정. 계좌는 전역 하나이고 학년도별로 나누지 않는다.
* <p>
* {@code api:app-api}에도 같은 모양의 record가 있다. 공유하지 않는 이유는 둘을 담을 자리가 없어서다 —
* {@code api:common-api}는 welfare·internal 모듈을 모르고, 그 의존을 추가하면 "role 무관 공통 인프라"라는
* 그 모듈의 역할이 깨진다. 설정 키는 {@code FeeConfigKeys} 하나를 같이 쓰므로 값이 어긋나지는 않는다.
*
* @param bank 설정 전이면 null
* @param accountNo 설정 전이면 null
* @param amount 학기당 회비. 설정 전이면 기본값
*/
public record FeeAccount(String bank, String accountNo, long amount) {

public boolean isConfigured() {
return bank != null && accountNo != null;
}

/** 금액이 빠진 송금 기본 링크. 계좌가 설정되기 전이면 null이다. */
public String generateTransferLinkUrl() {
return isConfigured() ? TossTransferLinkGenerator.generate(bank, accountNo, null) : null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee;

import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAccountUpdateRequest;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeBanks;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;

/**
* 회비 납부 설정 입력 검증. 항목마다 전용 에러 코드를 내려줘야 해서 Bean Validation 대신 직접 본다
* (fee-domain-study 08번 문서 — 애노테이션으로는 공통 INVALID_INPUT이 된다).
*/
final class FeeAccountValidator {

private static final int MIN_ACCOUNT_NO_LENGTH = 10;
private static final int MAX_ACCOUNT_NO_LENGTH = 14;

private FeeAccountValidator() {}

static void validate(FeeAccountUpdateRequest request) {
FeeBanks.validate(request.bank());
validateAccountNo(request.accountNo());
validateAmount(request.amount());
}

private static void validateAccountNo(String accountNo) {
if (accountNo == null
|| !accountNo.matches("^[0-9]+$")
|| accountNo.length() < MIN_ACCOUNT_NO_LENGTH
|| accountNo.length() > MAX_ACCOUNT_NO_LENGTH) {
throw new BusinessException(FeeErrorCode.INVALID_ACCOUNT_NO);
}
}

private static void validateAmount(Integer amount) {
if (amount == null || amount < 1) {
throw new BusinessException(FeeErrorCode.INVALID_TRANSFER_AMOUNT);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee.request;

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

/**
* 회비 납부 설정 변경. 계좌와 학기당 회비를 한 화면에서 같이 저장하므로 함께 받는다.
* <p>
* 검증에 Bean Validation을 쓰지 않는다 — 항목마다 전용 에러 코드를 내려줘야 하는데
* 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(fee-domain-study 08번 문서).
*/
public record FeeAccountUpdateRequest(
@Schema(description = "은행명 (허용 목록 중 하나)", example = "카카오뱅크")
String bank,

@Schema(description = "계좌번호 (하이픈 제외, 10~14자리)", example = "3333011234567")
String accountNo,

@Schema(description = "학기당 학생회비", example = "20000")
Integer amount
) {
}

This file was deleted.

This file was deleted.

Loading
Loading