Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f77f80a
feat: 학점이수현황 캡처 파일 카테고리와 키 조회 추가
xeoxxn Oct 25, 2026
fc3f1d7
feat: 학생회비 납부 요청을 이력으로 쌓고 사용자·관리자 API를 개편한다
xeoxxn Oct 27, 2026
0b34f7f
feat: 운영진 처리로 납부 여부가 바뀌면 아웃박스에 기록
xeoxxn Oct 25, 2026
3d4c93d
refactor: 회비 요청 검증을 Bean Validation으로 옮긴다
xeoxxn Oct 26, 2026
59e1fff
refactor: 회비 메서드 이름을 컨벤션에 맞춘다
xeoxxn Oct 26, 2026
c7e81f8
refactor: 회비 계좌 설정을 도메인 VO로 합친다
xeoxxn Oct 27, 2026
b673e59
refactor: 남은 학기 수와 송금 안내를 회비 도메인 VO로 옮긴다
xeoxxn Oct 27, 2026
681362f
refactor: 회비 상태 판단과 안내 문구 검증을 도메인으로 모은다
xeoxxn Oct 27, 2026
d556c78
refactor: 안 쓰는 회원별 최신 요청 조회를 지우고 검증 메서드 이름을 맞춘다
xeoxxn Oct 27, 2026
a1d2ee5
docs: 관리자 회비 API 문서와 에러 메시지를 실제 동작에 맞춘다
xeoxxn Oct 27, 2026
3712c94
refactor: 납부 여부 변경 이벤트 기록을 후속 member 작업으로 미룬다
xeoxxn Oct 27, 2026
96d37eb
refactor: 관리자 안내 문구를 반려 사유(rejectReason)로 이름을 바꾼다
xeoxxn Oct 27, 2026
497a897
refactor: 납부 요청 시각을 requested_at 대신 created_at으로 쓴다
xeoxxn Oct 27, 2026
3c2bd38
refactor: 회비 마이그레이션을 최종 스키마 기준 세 개로 합친다
xeoxxn Oct 27, 2026
2eb5ef1
feat: 가입을 마친 회원 ID를 한 번에 조회한다
xeoxxn Oct 27, 2026
6b98eb3
fix: 탈퇴 회원의 요청이 있어도 관리자 납부자 목록이 열리게 한다
xeoxxn Oct 27, 2026
abb0122
docs: 지운 FeeAmountCalculator를 가리키던 대여 주석을 고친다
xeoxxn Oct 27, 2026
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
@@ -0,0 +1,43 @@
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.FeeAccount;
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 FeeAccount.of(
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 amountPerSemester) {
FeeAccount account = FeeAccount.create(bank, accountNo, amountPerSemester);
adminConfigValueService.upsertValues(Map.of(
FeeConfigKeys.TRANSFER_BANK, account.bank(),
FeeConfigKeys.TRANSFER_ACCOUNT_NO, account.accountNo(),
FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(account.amountPerSemester())
));
return account;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@

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;
Expand All @@ -22,16 +20,15 @@
/**
* 운영진 학생회비 API의 문서 명세. 구현은 {@link AdminFeeController}가 맡는다.
* <p>
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
* 파라미터 바인딩(@{@code ModelAttribute}, @{@code RequestBody} 등), 부서 권한(@{@code RequireDepartment})은
* 구현체에 둔다.
* 계좌와 학기당 회비는 한 화면에서 같이 저장하므로 하나의 엔드포인트({@code /account})로 묶여 있다.
*/
@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 +38,35 @@ ApiResponse<PageResponse<AdminFeeSearchResponse>> getRequests(
@ParameterObject PageParams pageParams
);

/** 납부 확인 요청 처리. 승인/반려로 상태를 바꾸고 납부자 명부에 반영한다. */
@Operation(summary = "납부 확인 요청 처리",
description = "납부 확인 요청을 승인 또는 반려 처리하고 납부자 명부를 갱신한다.")
/** 납부 상태 처리. 이미 처리한 요청도 다시 바꿀 수 있다. */
@Operation(summary = "학생회비 납부 상태 수정",
description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. "
+ "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 반려 사유(255자 이하)가 필요하고, "
+ "다른 상태로 바꿀 때는 반려 사유를 보내도 저장하지 않는다. "
+ "확인필요로 돌려받은 학생은 반려 사유를 보고 학기 인증부터 다시 낼 수 있다. "
+ "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다. "
+ "드물게 학생이 새 요청을 내는 순간과 겹치면 늦게 저장되는 쪽이 FEE_REQUEST_ALREADY_REQUESTED로 실패할 수 있다.")
@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 = FeeErrorCode.class, codes = {"INVALID_REJECT_REASON", "FEE_REQUEST_NOT_FOUND",
"FEE_REQUEST_NOT_LATEST", "FEE_REQUEST_ALREADY_REQUESTED"})
@ApiErrorCode(type = MemberErrorCode.class, codes = {"MEMBER_NOT_FOUND"})
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 = "학생회비 송금 금액을 설정한다.")
/** 회비 납부 설정 변경. 계좌와 학기당 회비를 함께 받는다. */
@Operation(summary = "회비 납부 계좌 변경",
description = "은행·계좌번호·학기당 회비를 한 번에 저장한다. 은행은 허용 목록 중 하나여야 하고 "
+ "계좌번호는 하이픈을 제외한 숫자 10~14자리다. 송금 링크는 저장된 값으로 조회 시점에 만들어진다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
ApiResponse<FeeAmountUpdateResponse> updateAmount(
AdminApiUser apiUser,
FeeAmountUpdateRequest request
);
@ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_BANK"})
ApiResponse<FeeAccountResponse> updateAccount(AdminApiUser apiUser, FeeAccountUpdateRequest request);
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
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.FeeAccount;
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 +35,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 +48,42 @@ 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<MemberFeeStatus> 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, AdminFeeSearchResponse::from));
}

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

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
@Valid @RequestBody FeeAccountUpdateRequest request
) {
adminConfigValueService.upsertValue(FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(request.amount()));
return ApiResponse.success(FeeAmountUpdateResponse.of(request.amount()));
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,27 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;

/**
* 회비 납부 설정 변경. 계좌와 학기당 회비를 한 화면에서 같이 저장하므로 함께 받는다.
*/
public record FeeAccountUpdateRequest(
Comment thread
tnals0924 marked this conversation as resolved.
@Schema(description = "은행명 (허용 목록 중 하나)", example = "카카오뱅크")
@NotBlank(message = "은행을 선택해 주세요.")
String bank,

@Schema(description = "계좌번호 (하이픈 제외, 10~14자리)", example = "3333011234567")
@NotBlank(message = "계좌번호를 입력해 주세요.")
@Pattern(regexp = "^\\d{10,14}$", message = "계좌번호는 하이픈 없이 숫자 10~14자리로 입력해 주세요.")
String accountNo,

@Schema(description = "학기당 학생회비", example = "20000")
@NotNull(message = "학기당 학생회비를 입력해 주세요.")
@Positive(message = "학기당 학생회비는 1원 이상이어야 합니다.")
Integer amount
) {
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee.request;

import jakarta.validation.constraints.NotBlank;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;

/**
* 납부 상태 변경. 반려 사유는 확인필요(REJECTED)일 때만 필수라, 필수 여부와 길이는 도메인이 검증한다.
*/
public record FeeStatusUpdateRequest(
@NotBlank(message = "처리 상태를 입력해 주세요.")
String status
@Schema(description = "변경할 납부 상태", example = "REJECTED")
@NotNull(message = "변경할 납부 상태를 입력해 주세요.")
TransferStatus status,

@Schema(description = "학생에게 보여줄 반려 사유 (최대 255자). 확인필요일 때 필수",
example = "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.")
String rejectReason
) {
}
Loading