diff --git a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java index 9637e21..2178abb 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java @@ -1,24 +1,40 @@ package inha.gdgoc.domain.recruit.controller; +import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.MEMBER_LIST_RETRIEVED_SUCCESS; import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.MEMBER_RETRIEVED_SUCCESS; import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.MEMBER_SAVE_SUCCESS; +import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.PAYMENT_MARKED_COMPLETE_SUCCESS; +import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.PAYMENT_MARKED_INCOMPLETE_SUCCESS; import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS; import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.STUDENT_ID_DUPLICATION_CHECK_SUCCESS; import inha.gdgoc.domain.recruit.dto.request.ApplicationRequest; +import inha.gdgoc.domain.recruit.dto.request.PaymentUpdateRequest; import inha.gdgoc.domain.recruit.dto.response.CheckPhoneNumberResponse; import inha.gdgoc.domain.recruit.dto.response.CheckStudentIdResponse; +import inha.gdgoc.domain.recruit.dto.response.RecruitMemberSummaryResponse; import inha.gdgoc.domain.recruit.dto.response.SpecifiedMemberResponse; +import inha.gdgoc.domain.recruit.entity.RecruitMember; import inha.gdgoc.domain.recruit.service.RecruitMemberService; import inha.gdgoc.global.dto.response.ApiResponse; +import inha.gdgoc.global.dto.response.PageMeta; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -26,6 +42,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Recruit - Members", description = "리크루팅 지원자 관리 API") @RequestMapping("/api/v1") @RequiredArgsConstructor @RestController @@ -67,7 +84,7 @@ public ResponseEntity> duplicatedPho return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); } - @Operation(summary = "특정 멤버 가입 신청서 조회", security = { @SecurityRequirement(name = "BearerAuth") }) + @Operation(summary = "특정 멤버 가입 신청서 조회", security = {@SecurityRequirement(name = "BearerAuth")}) @PreAuthorize("hasRole('ADMIN')") @GetMapping("/recruit/members/{memberId}") public ResponseEntity> getSpecifiedMember( @@ -78,9 +95,64 @@ public ResponseEntity> getSpecifiedMe return ResponseEntity.ok(ApiResponse.ok(MEMBER_RETRIEVED_SUCCESS, response)); } - // TODO 전체 응답 조회 및 검색 + @Operation( + summary = "입금 상태 변경", + description = "설정하려는 상태(NOT 현재 상태)를 body에 보내주세요. true=입금 완료, false=입금 미완료", + security = { @SecurityRequirement(name = "BearerAuth") } + ) + @PreAuthorize("hasRole('ADMIN')") + @PatchMapping("/recruit/members/{memberId}/payment") + public ResponseEntity> updatePayment( + @PathVariable Long memberId, + @RequestBody PaymentUpdateRequest paymentUpdateRequest + ) { + recruitMemberService.updatePayment(memberId, paymentUpdateRequest.isPayed()); + + return ResponseEntity.ok( + ApiResponse.ok( + paymentUpdateRequest.isPayed() + ? PAYMENT_MARKED_COMPLETE_SUCCESS + : PAYMENT_MARKED_INCOMPLETE_SUCCESS + ) + ); + } + + @Operation( + summary = "지원자 목록 조회", + description = "전체 목록 또는 이름 검색 결과를 반환합니다. 검색어(question)를 주면 이름 포함 검색, 없으면 전체 조회. sort랑 dir은 example 값 그대로 코딩하는 것 추천...", + security = { @SecurityRequirement(name = "BearerAuth") } + ) + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/recruit/members") + public ResponseEntity, PageMeta>> getMembers( + @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "소연") + @RequestParam(required = false) String question, + + @Parameter(description = "페이지(0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, - // TODO 입금 완료 + @Parameter(description = "페이지 크기", example = "20") + @RequestParam(defaultValue = "20") int size, + + @Parameter(description = "정렬 필드", example = "createdAt") + @RequestParam(defaultValue = "createdAt") String sort, + + @Parameter(description = "정렬 방향 ASC/DESC", example = "DESC") + @RequestParam(defaultValue = "DESC") String dir + ) { + Direction direction = "ASC".equalsIgnoreCase(dir) ? Direction.ASC : Direction.DESC; + Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort)); + + Page memberPage = (question == null || question.isBlank()) + ? recruitMemberService.findAllMembersPage(pageable) + : recruitMemberService.searchMembersByNamePage(question, pageable); + + List list = memberPage + .map(RecruitMemberSummaryResponse::from) + .getContent(); + PageMeta meta = PageMeta.of(memberPage); + + return ResponseEntity.ok(ApiResponse.ok(MEMBER_LIST_RETRIEVED_SUCCESS, list, meta)); + } - // TODO 입금 미완료 } diff --git a/src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java b/src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java index 5142688..8e8a3ea 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java +++ b/src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java @@ -5,4 +5,7 @@ public class RecruitMemberMessage { public static final String STUDENT_ID_DUPLICATION_CHECK_SUCCESS = "성공적으로 학번 중복 조회를 완료했습니다."; public static final String PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS = "성공적으로 전화번호 중복 조회를 완료했습니다."; public static final String MEMBER_RETRIEVED_SUCCESS = "성공적으로 특정 멤버의 지원서를 조회했습니다."; + public static final String PAYMENT_MARKED_COMPLETE_SUCCESS = "성공적으로 입금 완료로 변경했습니다."; + public static final String PAYMENT_MARKED_INCOMPLETE_SUCCESS = "성공적으로 입금 미완료로 변경했습니다."; + public static final String MEMBER_LIST_RETRIEVED_SUCCESS = "성공적으로 멤버 목록을 조회했습니다."; } diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java b/src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java new file mode 100644 index 0000000..3a6f176 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.recruit.dto.request; + +public record PaymentUpdateRequest( + boolean isPayed +) { + +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java index 3baa07a..6f0886c 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java @@ -3,6 +3,7 @@ import inha.gdgoc.domain.recruit.entity.RecruitMember; import inha.gdgoc.domain.recruit.enums.EnrolledClassification; import inha.gdgoc.domain.recruit.enums.Gender; +import inha.gdgoc.global.util.SemesterCalculator; import java.time.LocalDate; import lombok.AllArgsConstructor; import lombok.Builder; @@ -41,6 +42,7 @@ public RecruitMember toEntity() { .major(major) .doubleMajor(doubleMajor) .isPayed(false) + .admissionSemester(SemesterCalculator.currentSemester()) .build(); } } diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/RecruitMemberSummaryResponse.java b/src/main/java/inha/gdgoc/domain/recruit/dto/response/RecruitMemberSummaryResponse.java new file mode 100644 index 0000000..1e6618c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/dto/response/RecruitMemberSummaryResponse.java @@ -0,0 +1,32 @@ +package inha.gdgoc.domain.recruit.dto.response; + +import inha.gdgoc.domain.recruit.entity.RecruitMember; + +public record RecruitMemberSummaryResponse( + Long id, + String name, + String phoneNumber, + String major, + String studentId, + String admissionSemester, + Boolean isPayed +) { + + public static RecruitMemberSummaryResponse from(RecruitMember recruitMember) { + String semester = null; + if (recruitMember.getAdmissionSemester() != null) { + String enumName = recruitMember.getAdmissionSemester().name(); + semester = enumName.substring(1).replace('_', '-'); + } + + return new RecruitMemberSummaryResponse( + recruitMember.getId(), + recruitMember.getName(), + recruitMember.getPhoneNumber(), + recruitMember.getMajor(), + recruitMember.getStudentId(), + semester, + recruitMember.getIsPayed() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java b/src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java index d51801a..b09d978 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java +++ b/src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java @@ -1,6 +1,7 @@ package inha.gdgoc.domain.recruit.entity; import com.fasterxml.jackson.annotation.JsonFormat; +import inha.gdgoc.domain.recruit.enums.AdmissionSemester; import inha.gdgoc.domain.recruit.enums.EnrolledClassification; import inha.gdgoc.domain.recruit.enums.Gender; import inha.gdgoc.global.entity.BaseEntity; @@ -67,13 +68,25 @@ public class RecruitMember extends BaseEntity { @Column(name = "major", nullable = false) private String major; - @Column(name = "double_major", nullable = true) + @Column(name = "double_major") private String doubleMajor; @Column(name = "is_payed", nullable = false) private Boolean isPayed; + @Enumerated(EnumType.STRING) + @Column(name = "admission_semester", nullable = false, length = 10) + private AdmissionSemester admissionSemester; + @Builder.Default @OneToMany(mappedBy = "recruitMember", cascade = CascadeType.ALL, orphanRemoval = true) private List answers = new ArrayList<>(); + + public void markPaid() { + this.isPayed = Boolean.TRUE; + } + + public void markUnpaid() { + this.isPayed = Boolean.FALSE; + } } diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java b/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java new file mode 100644 index 0000000..4485f2a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java @@ -0,0 +1,5 @@ +package inha.gdgoc.domain.recruit.enums; + +public enum AdmissionSemester { + Y25_1, Y25_2, Y26_1, Y26_2 +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java b/src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java index da6996e..04c0c08 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java +++ b/src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java @@ -2,8 +2,11 @@ import inha.gdgoc.domain.recruit.entity.RecruitMember; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface RecruitMemberRepository extends JpaRepository { boolean existsByStudentId(String studentId); boolean existsByPhoneNumber(String phoneNumber); + Page findByNameContainingIgnoreCase(String name, Pageable pageable); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java b/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java index f6f2255..3d7a7f5 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java @@ -14,10 +14,12 @@ import inha.gdgoc.domain.recruit.exception.RecruitMemberException; import inha.gdgoc.domain.recruit.repository.AnswerRepository; import inha.gdgoc.domain.recruit.repository.RecruitMemberRepository; -import jakarta.transaction.Transactional; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service @@ -67,4 +69,26 @@ public SpecifiedMemberResponse findSpecifiedMember(Long id) { return SpecifiedMemberResponse.from(member, answers, objectMapper); } + + @Transactional + public void updatePayment(Long memberId, boolean isPayed) { + RecruitMember m = recruitMemberRepository.findById(memberId) + .orElseThrow(() -> new RecruitMemberException(RECRUIT_MEMBER_NOT_FOUND)); + + if (Boolean.TRUE.equals(m.getIsPayed()) == isPayed) return; + + if (isPayed) m.markPaid(); + else m.markUnpaid(); + } + + @Transactional(readOnly = true) + public Page findAllMembersPage(Pageable pageable) { + return recruitMemberRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public Page searchMembersByNamePage(String name, Pageable pageable) { + return recruitMemberRepository.findByNameContainingIgnoreCase(name, pageable); + } + } diff --git a/src/main/java/inha/gdgoc/global/dto/response/PageMeta.java b/src/main/java/inha/gdgoc/global/dto/response/PageMeta.java new file mode 100644 index 0000000..91488f0 --- /dev/null +++ b/src/main/java/inha/gdgoc/global/dto/response/PageMeta.java @@ -0,0 +1,39 @@ +package inha.gdgoc.global.dto.response; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Sort; + +public record PageMeta( + int page, + int size, + long totalElements, + int totalPages, + boolean hasNext, + boolean hasPrevious, + String sort, + String direction +) { + + public static PageMeta of(Page page) { + String sortProps = page.getSort().stream() + .map(Sort.Order::getProperty) + .reduce((a, b) -> a + "," + b) + .orElse("createdAt"); + + String dir = page.getSort().stream() + .findFirst() + .map(o -> o.getDirection().name()) + .orElse("DESC"); + + return new PageMeta( + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.hasNext(), + page.hasPrevious(), + sortProps, + dir + ); + } +} diff --git a/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java b/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java new file mode 100644 index 0000000..fdbc60b --- /dev/null +++ b/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java @@ -0,0 +1,45 @@ +package inha.gdgoc.global.util; + +import inha.gdgoc.domain.recruit.enums.AdmissionSemester; + +import java.time.LocalDate; +import java.time.ZoneId; + +public final class SemesterCalculator { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private SemesterCalculator() {} + + public static AdmissionSemester currentSemester() { + return of(LocalDate.now(KST)); + } + + public static AdmissionSemester of(LocalDate date) { + int year = date.getYear(); + int month = date.getMonthValue(); + + int yy; + int term; + + if (month == 1) { + yy = (year - 1) % 100; + term = 2; + } else if (month <= 7) { + yy = year % 100; + term = 1; + } else { + yy = year % 100; + term = 2; + } + + String enumName = String.format("Y%02d_%d", yy, term); + try { + return AdmissionSemester.valueOf(enumName); + } catch (Exception ex) { + throw new RuntimeException( + "AdmissionSemester enum에 상수 " + enumName + " 이(가) 정의되어 있지 않습니다. " + + "해당 연도/학기 상수를 추가하세요.", ex + ); + } + } +} diff --git a/src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql b/src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql new file mode 100644 index 0000000..e56653c --- /dev/null +++ b/src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql @@ -0,0 +1,24 @@ +-- 1) 컬럼 추가 (처음엔 NULL 허용) +ALTER TABLE recruit_member + ADD COLUMN admission_semester VARCHAR(10); + +-- 2) 기존 데이터 백필 +-- 학기 규칙: 2~7월 = YYY_1, 8~12월 = YYY_2, 1월 = (전년도) YYY_2 +UPDATE recruit_member +SET admission_semester = CASE + WHEN EXTRACT(MONTH FROM created_at) BETWEEN 8 AND 12 + THEN 'Y' || to_char(created_at, 'YY') || '_2' + WHEN EXTRACT(MONTH FROM created_at) BETWEEN 2 AND 7 + THEN 'Y' || to_char(created_at, 'YY') || '_1' + WHEN EXTRACT(MONTH FROM created_at) = 1 + THEN 'Y' || to_char(created_at - INTERVAL '1 year', 'YY') || '_2' + END +WHERE admission_semester IS NULL; + +-- 3) NOT NULL 전환 (형식 제약은 생략 가능) +ALTER TABLE recruit_member + ALTER COLUMN admission_semester SET NOT NULL; + +-- (선택) 인덱스 +CREATE INDEX idx_recruit_member_admission_semester + ON recruit_member (admission_semester);