Skip to content
Merged
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
@@ -1,31 +1,48 @@
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;
import org.springframework.web.bind.annotation.RequestMapping;
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
Expand Down Expand Up @@ -67,7 +84,7 @@ public ResponseEntity<ApiResponse<CheckPhoneNumberResponse, Void>> 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<ApiResponse<SpecifiedMemberResponse, Void>> getSpecifiedMember(
Expand All @@ -78,9 +95,64 @@ public ResponseEntity<ApiResponse<SpecifiedMemberResponse, Void>> 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<ApiResponse<Void, Void>> 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<ApiResponse<List<RecruitMemberSummaryResponse>, 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<RecruitMember> memberPage = (question == null || question.isBlank())
? recruitMemberService.findAllMembersPage(pageable)
: recruitMemberService.searchMembersByNamePage(question, pageable);

List<RecruitMemberSummaryResponse> list = memberPage
.map(RecruitMemberSummaryResponse::from)
.getContent();
PageMeta meta = PageMeta.of(memberPage);

return ResponseEntity.ok(ApiResponse.ok(MEMBER_LIST_RETRIEVED_SUCCESS, list, meta));
}
Comment on lines +120 to +156
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

목록 조회 API: 파라미터 검증 추가와 정렬 컬럼 화이트리스트 권장

  • page/size 음수 등으로 PageRequest.of가 예외를 던지면 500으로 번질 수 있습니다. Bean Validation으로 400을 반환하도록 하는 것이 안전합니다.
  • 임의의 sort 값은 스프링 데이터에서 PropertyReferenceException을 유발할 수 있습니다. 허용 컬럼을 제한하세요.

검증 추가(diff):

-            @RequestParam(defaultValue = "0") int page,
+            @Min(0) @RequestParam(defaultValue = "0") int page,
@@
-            @RequestParam(defaultValue = "20") int size,
+            @Min(1) @Max(100) @RequestParam(defaultValue = "20") int size,

정렬 컬럼 화이트리스트(예시)(diff):

-        Direction direction = "ASC".equalsIgnoreCase(dir) ? Direction.ASC : Direction.DESC;
-        Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort));
+        Direction direction = "ASC".equalsIgnoreCase(dir) ? Direction.ASC : Direction.DESC;
+        // 실제 엔티티 필드에 맞게 컬럼 목록을 조정하세요.
+        String sortKey = switch (sort) {
+            case "createdAt", "name", "admissionSemester" -> sort;
+            default -> "createdAt";
+        };
+        Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortKey));

추가 import:

// 추가 import 필요
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;

운영 수위 제안:

  • size 상한은 운영 부하를 고려해 50~100선에서 조정하세요(현재 예시로 100 사용).
  • 필요 시 question = question == null ? null : question.trim();으로 공백만 있는 입력을 일찍 정규화할 수 있습니다.


// TODO 입금 미완료
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "성공적으로 멤버 목록을 조회했습니다.";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package inha.gdgoc.domain.recruit.dto.request;

public record PaymentUpdateRequest(
boolean isPayed
) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +42,7 @@ public RecruitMember toEntity() {
.major(major)
.doubleMajor(doubleMajor)
.isPayed(false)
.admissionSemester(SemesterCalculator.currentSemester())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Answer> answers = new ArrayList<>();

public void markPaid() {
this.isPayed = Boolean.TRUE;
}

public void markUnpaid() {
this.isPayed = Boolean.FALSE;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package inha.gdgoc.domain.recruit.enums;

public enum AdmissionSemester {
Y25_1, Y25_2, Y26_1, Y26_2
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecruitMember, Long> {
boolean existsByStudentId(String studentId);
boolean existsByPhoneNumber(String phoneNumber);
Page<RecruitMember> findByNameContainingIgnoreCase(String name, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<RecruitMember> findAllMembersPage(Pageable pageable) {
return recruitMemberRepository.findAll(pageable);
}

@Transactional(readOnly = true)
public Page<RecruitMember> searchMembersByNamePage(String name, Pageable pageable) {
return recruitMemberRepository.findByNameContainingIgnoreCase(name, pageable);
}

}
39 changes: 39 additions & 0 deletions src/main/java/inha/gdgoc/global/dto/response/PageMeta.java
Original file line number Diff line number Diff line change
@@ -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
);
}
}
45 changes: 45 additions & 0 deletions src/main/java/inha/gdgoc/global/util/SemesterCalculator.java
Original file line number Diff line number Diff line change
@@ -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
);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
Loading