Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
) {
Comment on lines +3 to +5
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

PATCH DTO에서 primitive boolean 사용은 위험합니다: 누락 시 false로 바인딩되어 오작동 가능

PATCH 시 필드가 누락되면 primitive booleanfalse로 디시리얼라이즈되어 의도치 않게 “미납”으로 변경될 수 있습니다. Boolean + Bean Validation(@NotNull)로 바꾸고, 컨트롤러 파라미터에 @Valid를 적용해 주세요.

권장 수정안:

+import jakarta.validation.constraints.NotNull;
 
 public record PaymentUpdateRequest(
-        boolean isPayed
+        @NotNull Boolean isPayed
 ) {
 }

컨트롤러에서의 유효성 검사 적용도 확인 필요합니다:


🏁 Script executed:

#!/bin/bash
# PaymentUpdateRequest가 @Valid로 검증되는지 확인
rg -nP -C3 '@PatchMapping\([^)]*payment[^)]*\)' src/main/java | sed -n '1,120p'
rg -n 'PaymentUpdateRequest' src/main/java | sed -n '1,120p'

Length of output: 1487


PATCH DTO와 컨트롤러 유효성 검사 개선 필요

원격 PATCH 요청에서 primitive boolean을 그대로 쓰면, 클라이언트가 해당 필드를 아예 보내지 않았을 때(false로 디시리얼라이즈) 의도치 않은 “미납” 상태로 변경될 위험이 있습니다. 또한 컨트롤러 메서드에 @Valid 검증이 빠져 있어, Bean Validation이 전혀 동작하지 않습니다.

수정해야 할 지점:

  • src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java
    • 필드 타입을 primitive boolean → 객체형 Boolean으로 변경
    @NotNull 애너테이션 추가 → 누락 시 400 Bad Request 반환
  • src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java
    @RequestBody PaymentUpdateRequest paymentUpdateRequest@Valid @RequestBody PaymentUpdateRequest paymentUpdateRequest로 변경
    import jakarta.validation.Valid; (또는 javax.validation.Valid;) 추가

권장 수정 예시:

--- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java
+++ b/src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java
@@
-public record PaymentUpdateRequest(
-       boolean isPayed
-) {
+import jakarta.validation.constraints.NotNull;
+
+public record PaymentUpdateRequest(
+       @NotNull Boolean isPayed
+) {
 }
--- a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java
+++ b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java
@@
-    public ResponseEntity<ApiResponse<Void, Void>> updatePayment(
-            @PathVariable Long memberId,
-            @RequestBody PaymentUpdateRequest paymentUpdateRequest
+)    public ResponseEntity<ApiResponse<Void, Void>> updatePayment(
+            @PathVariable Long memberId,
+            @Valid @RequestBody PaymentUpdateRequest paymentUpdateRequest

위 두 군데를 반영한 뒤에, 실제로 유효성 검사 예외가 컨트롤러로 전달되는지 통합테스트 등을 통해 반드시 확인해 주세요.

🤖 Prompt for AI Agents
In src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java
(around lines 3-5) change the primitive field type from boolean to the wrapper
Boolean and annotate it with @NotNull so missing JSON field yields a 400; add
the necessary import for jakarta.validation.constraints.NotNull. In
src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java
update the controller method signature to accept @Valid @RequestBody
PaymentUpdateRequest paymentUpdateRequest and add the import for
jakarta.validation.Valid so Bean Validation runs and missing/invalid payloads
are rejected.


}
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;
Comment on lines +77 to +79
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Enum 매핑과 마이그레이션 값 불일치 가능 — 과거 데이터 로딩 실패 위험(중대)

DB 마이그레이션은 과거 레코드에 Y24_1, Y24_2 … 형태를 백필합니다. 그런데 AdmissionSemester enum이 Y25_1, Y25_2, Y26_1, Y26_2 정도로 제한되어 있다면, JPA @Enumerated(EnumType.STRING) 매핑은 과거 값 로딩 시 즉시 IllegalArgumentException(No enum constant …)로 실패합니다. 목록 조회/검색 시 런타임 장애로 이어집니다.

해결 옵션:

  • 옵션 A(가장 유연): JPA 필드를 String으로 보관하고, 패턴 검증(DB CHECK + Bean Validation)과 값 객체(파서/포매터)로 도메인 계층에서 타입안전 확보.
  • 옵션 B(차선): AttributeConverter로 문자열(Y25_1 등)을 파싱해 커스텀 값 객체(비 enum)로 매핑.
  • 옵션 C(단기): Enum을 과거미래 범위까지 대량 확장(운영 히스토리에 맞춰 최소 20202035 등). 다만 유지보수 비용 큽니다.

필드만 String으로 전환하는 최소 수정안 예시:

-    @Enumerated(EnumType.STRING)
-    @Column(name = "admission_semester", nullable = false, length = 10)
-    private AdmissionSemester admissionSemester;
+    // 과거/미래 학기 값을 폭넓게 수용하기 위해 문자열로 저장합니다.
+    @Column(name = "admission_semester", nullable = false, length = 10)
+    private String admissionSemester;

후속 영향:

  • RecruitMemberRequest.toEntity()가 SemesterCalculator.currentSemester() (enum 반환) 사용 중이면, 문자열 코드(예: "Y25_1")를 반환하는 헬퍼로 교체 필요.
  • Summary/Specified DTO 매핑에서 enum.name() 접근 부를 문자열 가공으로 교체 필요.

원하시면 값 객체 + Converter 방식으로 안전하게 리팩터링하는 PR 패치를 드리겠습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Enumerated(EnumType.STRING)
@Column(name = "admission_semester", nullable = false, length = 10)
private AdmissionSemester admissionSemester;
// 과거/미래 학기 값을 폭넓게 수용하기 위해 문자열로 저장합니다.
@Column(name = "admission_semester", nullable = false, length = 10)
private String admissionSemester;
🤖 Prompt for AI Agents
In src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java around
lines 77-79, the AdmissionSemester field is mapped as an Enum with
@Enumerated(EnumType.STRING) which will fail loading legacy DB values like
"Y24_1" causing IllegalArgumentException; change the field to a String (persist
raw code), add validation (DB CHECK + Bean Validation pattern) and introduce
either a domain value object or an AttributeConverter to parse/format semester
codes for domain logic; then update callers (e.g.,
RecruitMemberRequest.toEntity() to supply the semester code string instead of an
enum, and DTO mappings that used enum.name() to use the string) — Option A
(store String + value object/validator) is recommended, Option B
(AttributeConverter) acceptable, Option C (expanding enum) is last-resort.


@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
}
Comment on lines +3 to +5
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

열거값 범위 한정으로 인한 런타임 예외 위험: 1월 처리와 향후 연도 대비 필요

SemesterCalculator.of()는 1월을 “이전 해 2학기”로 산출합니다. 예: 2025-01 → Y24_2. 현재 enum에는 Y24_2가 없어 해당 시점 호출 시 예외가 발생합니다. 또한 2028-01(→ Y27_2)도 대비가 없습니다. 최소 과거 1개 학기 및 향후 2~3년치를 선반영하세요.

권장 수정안(예시: 과거 24년 2학기, 미래 27년까지 확장):

 public enum AdmissionSemester {
-    Y25_1, Y25_2, Y26_1, Y26_2
+    Y24_2, Y25_1, Y25_2, Y26_1, Y26_2, Y27_1, Y27_2
 }

추가로, 매년 연말 CI에서 다음 해 상수 누락을 검출하는 가드 테스트를 두는 것을 권장합니다.


다음 스크립트로 AdmissionSemester 열거값과 SemesterCalculator.of() 구현을 검증해 보겠습니다.


🏁 Script executed:

#!/bin/bash
# AdmissionSemester enum 확인
echo '== AdmissionSemester enum ==' 
sed -n '1,200p' src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java

# SemesterCalculator 클래스 위치 확인
echo '== SemesterCalculator 클래스 위치 ==' 
rg -n 'class SemesterCalculator' -C3 src/main/java/inha/gdgoc/domain/recruit

# SemesterCalculator.of() 구현 확인
echo '== SemesterCalculator.of() 구현 ==' 
rg -nP 'public static.*AdmissionSemester of' -C5 src/main/java/inha/gdgoc/domain/recruit

Length of output: 576


SemesterCalculator.of() 구현을 직접 확인하기 위해 전체 프로젝트에서 해당 클래스와 메서드를 찾아보겠습니다.


🏁 Script executed:

#!/bin/bash
# 1) SemesterCalculator 클래스 파일 경로 조회
FILE=$(rg -l 'class SemesterCalculator' -g '*.java' .)
echo "SemesterCalculator 위치: $FILE"

# 2) 클래스 상단 200줄 출력
echo "== SemesterCalculator 초기 부분 =="
sed -n '1,200p' "$FILE"

# 3) of() 메서드 구현부 출력
echo "== of() 메서드 구현 =="
rg -n 'of\s*\(' -C5 "$FILE"

# 4) 연도·월 계산 로직 확인 (getYear, getMonthValue 탐색)
echo "== 연도·월 계산 참조 =="
rg -n -C3 'getYear|getMonthValue' "$FILE"

Length of output: 2626


AdmissionSemester enum에 과거·미래 학기 추가 필요

  • SemesterCalculator.of()에서 1월(month == 1)에 이전 해 2학기(예: 2025-01 → Y24_2)를 참조하도록 설계되어 있으나, 현재 AdmissionSemesterY24_2가 없어 2025년 1월 호출 시 런타임 예외 발생
  • 향후 2년치(2027년) 학기(2학기: Y27_2, 1학기: Y27_1)도 누락되어 Feb–Jul 또는 Aug–Jan(1월) 호출 시 예외 위험

대응 방안(예시):

--- a/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java
+++ b/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java
@@ public enum AdmissionSemester {
-    Y25_1, Y25_2, Y26_1, Y26_2
+    Y24_2, Y25_1, Y25_2, Y26_1, Y26_2, Y27_1, Y27_2
 }
  • 매년 연말 또는 CI 파이프라인에서 다음 해·학기 상수 누락을 감지하는 가드 테스트 추가를 권장합니다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public enum AdmissionSemester {
Y25_1, Y25_2, Y26_1, Y26_2
}
public enum AdmissionSemester {
Y24_2, Y25_1, Y25_2, Y26_1, Y26_2, Y27_1, Y27_2
}
🤖 Prompt for AI Agents
In src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java around
lines 3 to 5, the enum lacks past and future semester constants (e.g., Y24_2 and
upcoming Y27_1/Y27_2), causing SemesterCalculator.of() to throw at runtime for
months that reference previous or future years; add the missing semester enum
values covering at least the previous year’s second semester (Y24_2) and the
next two years’ semesters (e.g., Y26_1/Y26_2 already present so add Y27_1 and
Y27_2) so all month-to-semester mappings resolve, and consider adding a CI/unit
test that verifies the enum contains the expected range of year/semester
constants each release.

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,27 @@ 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 IllegalArgumentException("RecruitMember not found: " + memberId));

// 멱등 처리: 이미 동일 상태면 스킵
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