Skip to content

Commit a61276d

Browse files
hodoonysw789
andauthored
feat: 면접 예약 API 구현
* feat: 면접 예약 API 구현 * feat: 면접 예약 API InterviewSlotRepository 쿼리 추가 * feat: 면접 예약 API * fix: 면접 예약 API 수정 완료 * feat: Google Credentials 주입 방식 파일이 아닌 JSON 값으로 받도록 수정 - 그 외 코드 포맷팅 개선 * chore: .gitignore 적용되지 않는 항목 제거 * fix: 면접 예약 API 수정 완료 * fix: 면접 예약 API 수정 완료 --------- Co-authored-by: Seungwan Yoo <[email protected]>
1 parent 8527c17 commit a61276d

22 files changed

+696
-54
lines changed

.gitignore

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,3 @@ out/
4242
### dev ###
4343
application-dev.yml
4444

45-
### google ###
46-
src/main/resources/credentials/dasomGoogle.json
47-
48-
### google ###
49-
src/main/resources/credentials/dasomGoogle.json

src/main/java/dmu/dasom/api/domain/applicant/entity/Applicant.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public List<Object> toGoogleSheetRow(){
9292
this.email,
9393
this.grade,
9494
this.reasonForApply,
95-
this.activityWish,
95+
this.activityWish != null ? this.activityWish : "없음", // null일 경우 "없음" 반환
9696
this.isPrivacyPolicyAgreed,
9797
this.status.name(),
9898
this.createdAt.toString(),

src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import dmu.dasom.api.domain.applicant.entity.Applicant;
44
import dmu.dasom.api.domain.applicant.enums.ApplicantStatus;
5+
import io.lettuce.core.dynamic.annotation.Param;
56
import org.springframework.data.domain.Page;
67
import org.springframework.data.domain.Pageable;
78
import org.springframework.data.jpa.repository.JpaRepository;
@@ -20,4 +21,8 @@ public interface ApplicantRepository extends JpaRepository<Applicant, Long> {
2021

2122
Optional<Applicant> findByStudentNo(final String studentNo);
2223

24+
@Query("SELECT a FROM Applicant a WHERE a.studentNo = :studentNo AND a.contact LIKE %:contactLastDigits")
25+
Optional<Applicant> findByStudentNoAndContactEndsWith(@Param("studentNo") String studentNo,
26+
@Param("contactLastDigits") String contactLastDigits);
27+
2328
}

src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ public enum ErrorCode {
2626
INVALID_TIME_FORMAT(400, "C017", "시간 형식이 올바르지 않습니다."),
2727
INVALID_INQUIRY_PERIOD(400, "C018", "조회 기간이 아닙니다."),
2828
SHEET_WRITE_FAIL(400, "C019", "시트에 데이터를 쓰는데 실패하였습니다."),
29-
SHEET_READ_FAIL(400, "C200", "시트에 데이터를 쓰는데 실패하였습니다."),
29+
SHEET_READ_FAIL(400, "C200", "시트에 데이터를 읽는데 실패하였습니다."),
30+
SLOT_NOT_FOUND(400, "C021", "슬롯을 찾을 수 없습니다."),
31+
APPLICANT_NOT_FOUND(400, "C022", "지원자를 찾을 수 없습니다."),
32+
ALREADY_RESERVED(400, "C023", "이미 예약된 지원자입니다."),
33+
RESERVED_SLOT_CANNOT_BE_DELETED(400, "C024", "예약된 슬롯은 삭제할 수 없습니다."),
34+
SLOT_FULL(400, "C025", "해당 슬롯이 가득 찼습니다."),
35+
RESERVATION_NOT_FOUND(400, "C026", "예약을 찾을 수 없습니다."),
36+
SLOT_NOT_ACTIVE(400, "C027", "해당 슬롯이 비활성화 되었습니다."),
3037
FILE_ENCODE_FAIL(400, "C028", "파일 인코딩에 실패하였습니다.")
3138
;
3239

src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java

Lines changed: 57 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,44 +14,52 @@
1414
import dmu.dasom.api.domain.common.exception.CustomException;
1515
import dmu.dasom.api.domain.common.exception.ErrorCode;
1616
import lombok.RequiredArgsConstructor;
17-
import org.slf4j.Logger;
18-
import org.slf4j.LoggerFactory;
17+
import lombok.extern.slf4j.Slf4j;
1918
import org.springframework.beans.factory.annotation.Value;
2019
import org.springframework.core.io.ClassPathResource;
2120
import org.springframework.stereotype.Service;
2221

22+
import java.io.ByteArrayInputStream;
2323
import java.io.IOException;
2424
import java.security.GeneralSecurityException;
2525
import java.util.ArrayList;
26+
import java.util.Base64;
2627
import java.util.Collections;
2728
import java.util.List;
2829

2930
@RequiredArgsConstructor
3031
@Service
32+
@Slf4j
3133
public class GoogleApiService {
3234

33-
private static final Logger logger = LoggerFactory.getLogger(GoogleApiService.class);
34-
private static final String APPLICATION_NAME = "Recruit Form";
35-
private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
36-
@Value("${google.credentials.file.path}")
37-
private String credentialsFilePath;
35+
@Value("${google.credentials.json}")
36+
private String credentialsJson;
37+
3838
@Value("${google.spreadsheet.id}")
3939
private String spreadSheetId;
40+
41+
private static final String APPLICATION_NAME = "Recruit Form";
42+
private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
43+
4044
private Sheets sheetsService;
4145

4246
// Google Sheets API 서비스 객체를 생성하는 메소드
43-
private Sheets getSheetsService() throws IOException, GeneralSecurityException{
44-
if(sheetsService == null){
45-
ClassPathResource resource = new ClassPathResource(credentialsFilePath);
47+
private Sheets getSheetsService() throws IOException, GeneralSecurityException {
48+
if (sheetsService == null) {
49+
ByteArrayInputStream decodedStream = new ByteArrayInputStream(Base64.getDecoder()
50+
.decode(credentialsJson));
51+
4652
GoogleCredentials credentials = GoogleCredentials
47-
.fromStream(resource.getInputStream())
48-
.createScoped(Collections.singletonList("https://www.googleapis.com/auth/spreadsheets"));
49-
50-
sheetsService = new Sheets.Builder(GoogleNetHttpTransport.newTrustedTransport(),
51-
JSON_FACTORY,
52-
new HttpCredentialsAdapter(credentials))
53-
.setApplicationName(APPLICATION_NAME)
54-
.build();
53+
.fromStream(decodedStream)
54+
.createScoped(Collections.singletonList("https://www.googleapis.com/auth/spreadsheets"));
55+
56+
sheetsService = new Sheets.Builder(
57+
GoogleNetHttpTransport.newTrustedTransport(),
58+
JSON_FACTORY,
59+
new HttpCredentialsAdapter(credentials)
60+
)
61+
.setApplicationName(APPLICATION_NAME)
62+
.build();
5563
}
5664
return sheetsService;
5765
}
@@ -60,12 +68,13 @@ public void writeToSheet(String spreadsheetId, String range, List<List<Object>>
6068
try {
6169
Sheets service = getSheetsService();
6270
ValueRange body = new ValueRange().setValues(values);
63-
service.spreadsheets().values()
64-
.update(spreadsheetId, range, body)
65-
.setValueInputOption("USER_ENTERED")
66-
.execute();
67-
} catch (IOException | GeneralSecurityException e) {
68-
logger.error("구글 시트에 데이터를 쓰는 데 실패했습니다.", e);
71+
service.spreadsheets()
72+
.values()
73+
.update(spreadsheetId, range, body)
74+
.setValueInputOption("USER_ENTERED")
75+
.execute();
76+
} catch (IOException | GeneralSecurityException e) {
77+
log.error("시트에 데이터를 쓰는 데 실패했습니다.");
6978
throw new CustomException(ErrorCode.WRITE_FAIL);
7079
}
7180
}
@@ -78,32 +87,34 @@ public void updateSheet(List<Applicant> applicants) {
7887
processSheetsUpdate(applicants, false);
7988
}
8089

81-
public int findRowIndexByStudentNo(String spreadSheetId, String sheetName, String studentNo){
90+
public int findRowIndexByStudentNo(String spreadSheetId, String sheetName, String studentNo) {
8291
try {
8392
List<List<Object>> rows = readSheet(spreadSheetId, sheetName + "!A:L"); // A열부터 L열까지 읽기
8493

85-
for (int i = 0; i < rows.size(); i++){
94+
for (int i = 0; i < rows.size(); i++) {
8695
List<Object> row = rows.get(i);
87-
if(!row.isEmpty() && row.get(2).equals(studentNo)){ // 학번(Student No)이 3번째 열(A=0 기준)
96+
if (!row.isEmpty() && row.get(2)
97+
.equals(studentNo)) { // 학번(Student No)이 3번째 열(A=0 기준)
8898
return i + 1;
8999
}
90100
}
91101
} catch (Exception e) {
92-
logger.error("구글시트에서 행 찾기 실패", e);
102+
log.error("시트에서 행 찾기 실패");
93103
}
94104
return -1;
95105
}
96106

97107
public List<List<Object>> readSheet(String spreadsheetId, String range) {
98108
try {
99109
Sheets service = getSheetsService();
100-
ValueRange response = service.spreadsheets().values()
101-
.get(spreadsheetId, range)
102-
.execute();
110+
ValueRange response = service.spreadsheets()
111+
.values()
112+
.get(spreadsheetId, range)
113+
.execute();
103114

104115
return response.getValues();
105116
} catch (IOException | GeneralSecurityException e) {
106-
logger.error("시트에서 데이터를 읽어오는데 실패했습니다.", e);
117+
log.error("시트에서 데이터를 읽어오는 데 실패했습니다.");
107118
throw new CustomException(ErrorCode.SHEET_READ_FAIL);
108119
}
109120
}
@@ -113,7 +124,7 @@ public int getLastRow(String spreadsheetId, String sheetName) {
113124
List<List<Object>> rows = readSheet(spreadsheetId, sheetName + "!A:L"); // A~L 열까지 읽기
114125
return rows == null ? 0 : rows.size(); // 데이터가 없으면 0 반환
115126
} catch (Exception e) {
116-
logger.error("Failed to retrieve last row from Google Sheet", e);
127+
log.error("시트에서 마지막 행 찾기 실패");
117128
throw new CustomException(ErrorCode.SHEET_READ_FAIL);
118129
}
119130
}
@@ -124,27 +135,28 @@ public void batchUpdateSheet(String spreadsheetId, List<ValueRange> valueRanges)
124135

125136
// BatchUpdate 요청 생성
126137
BatchUpdateValuesRequest batchUpdateRequest = new BatchUpdateValuesRequest()
127-
.setValueInputOption("USER_ENTERED") // 사용자 입력 형식으로 값 설정
128-
.setData(valueRanges); // 여러 ValueRange 추가
138+
.setValueInputOption("USER_ENTERED") // 사용자 입력 형식으로 값 설정
139+
.setData(valueRanges); // 여러 ValueRange 추가
129140

130141
// BatchUpdate 실행
131-
BatchUpdateValuesResponse response = service.spreadsheets().values()
132-
.batchUpdate(spreadsheetId, batchUpdateRequest)
133-
.execute();
142+
BatchUpdateValuesResponse response = service.spreadsheets()
143+
.values()
144+
.batchUpdate(spreadsheetId, batchUpdateRequest)
145+
.execute();
134146

135-
logger.info("Batch update completed. Total updated rows: {}", response.getTotalUpdatedRows());
147+
log.info("시트 업데이트 성공. {}", response.getTotalUpdatedRows());
136148
} catch (IOException | GeneralSecurityException e) {
137-
logger.error("Batch update failed", e);
149+
log.error("시트 업데이트 실패.");
138150
throw new CustomException(ErrorCode.SHEET_WRITE_FAIL);
139151
}
140152
}
141153

142154

143155
private ValueRange createValueRange(String range, List<List<Object>> values) {
144156
return new ValueRange()
145-
.setRange(range)
146-
.setMajorDimension("ROWS") // 행 단위로 데이터 설정
147-
.setValues(values);
157+
.setRange(range)
158+
.setMajorDimension("ROWS") // 행 단위로 데이터 설정
159+
.setValues(values);
148160
}
149161

150162
public void processSheetsUpdate(List<Applicant> applicants, boolean isAppend) {
@@ -160,7 +172,7 @@ public void processSheetsUpdate(List<Applicant> applicants, boolean isAppend) {
160172
} else {
161173
int rowIndex = findRowIndexByStudentNo(spreadSheetId, "Sheet1", applicant.getStudentNo());
162174
if (rowIndex == -1) {
163-
logger.warn("구글시트에서 사용자를 찾을 수 없습니다. : {}", applicant.getStudentNo());
175+
log.warn("시트에서 지원자를 찾을 수 없습니다. : {}", applicant.getStudentNo());
164176
continue;
165177
}
166178
range = "Sheet1!A" + rowIndex + ":L" + rowIndex;
@@ -170,11 +182,10 @@ public void processSheetsUpdate(List<Applicant> applicants, boolean isAppend) {
170182

171183
batchUpdateSheet(spreadSheetId, valueRanges);
172184
} catch (Exception e) {
173-
logger.error("구글시트 업데이트에 실패했습니다.", e);
185+
log.error("시트 업데이트에 실패했습니다.", e);
174186
throw new CustomException(ErrorCode.SHEET_WRITE_FAIL);
175187
}
176188
}
177189

178190

179-
180191
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package dmu.dasom.api.domain.interview.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotNull;
5+
import jakarta.validation.constraints.Pattern;
6+
import lombok.*;
7+
8+
@Getter
9+
@Setter
10+
@NoArgsConstructor
11+
@AllArgsConstructor
12+
@Builder
13+
@Schema(name = "InterviewReservationResponseDto", description = "면접 예약 응답 DTO")
14+
public class InterviewReservationRequestDto {
15+
16+
@NotNull(message = "슬롯 ID는 필수 값입니다.")
17+
@Schema(description = "예약할 면접 슬롯의 ID", example = "1")
18+
private Long slotId; // 예약할 슬롯 ID
19+
20+
@NotNull(message = "예약 코드는 필수 값입니다.")
21+
@Pattern(regexp = "^[0-9]{8}[0-9]{4}$", message = "예약 코드는 학번 전체와 전화번호 뒤 4자리로 구성되어야 합니다.")
22+
@Schema(description = "학번 전체와 전화번호 뒤 4자리로 구성된 예약 코드", example = "202500010542")
23+
private String reservationCode; // 학번 전체 + 전화번호 뒤 4자리 조합 코드
24+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package dmu.dasom.api.domain.interview.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotNull;
5+
import lombok.*;
6+
7+
@Getter
8+
@Setter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
@Builder
12+
@Schema(name = "InterviewSlotCreateRequestDto", description = "면접 슬롯 생성 요청 DTO")
13+
public class InterviewReservationResponseDto {
14+
15+
@NotNull(message = "예약 ID는 필수 값입니다.")
16+
@Schema(description = "예약의 고유 ID", example = "1")
17+
private Long reservationId; // 예약 ID
18+
19+
@NotNull(message = "슬롯 ID는 필수 값입니다.")
20+
@Schema(description = "예약된 면접 슬롯의 ID", example = "10")
21+
private Long slotId; // 슬롯 ID
22+
23+
@NotNull(message = "지원자 ID는 필수 값입니다.")
24+
@Schema(description = "예약한 지원자의 ID", example = "1001")
25+
private Long applicantId; // 지원자 ID
26+
27+
@NotNull(message = "예약 코드는 필수 값입니다.")
28+
@Schema(description = "학번 전체와 전화번호 뒤 4자리로 구성된 예약 코드", example = "202500010542")
29+
private String reservationCode; // 예약 코드 (학번+전화번호 조합)
30+
31+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package dmu.dasom.api.domain.interview.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotNull;
5+
import lombok.*;
6+
7+
import java.time.LocalDate;
8+
import java.time.LocalTime;
9+
10+
@Getter
11+
@Setter
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
@Builder
15+
@Schema(name = "InterviewSlotCreateRequestDto", description = "면접 슬롯 생성 요청 DTO")
16+
public class InterviewSlotCreateRequestDto {
17+
18+
@NotNull(message = "시작 날짜는 필수 값입니다.")
19+
@Schema(description = "면접 시작 날짜", example = "2025-03-12")
20+
private LocalDate startDate; // 면접 시작 날짜
21+
22+
@NotNull(message = "종료 날짜는 필수 값입니다.")
23+
@Schema(description = "면접 종료 날짜", example = "2025-03-14")
24+
private LocalDate endDate; // 면접 종료 날짜
25+
26+
@NotNull(message = "시작 시간은 필수 값입니다.")
27+
@Schema(description = "하루의 시작 시간", example = "10:00")
28+
private LocalTime startTime; // 하루의 시작 시간
29+
30+
@NotNull(message = "종료 시간은 필수 값입니다.")
31+
@Schema(description = "하루의 종료 시간", example = "17:00")
32+
private LocalTime endTime; // 하루의 종료 시간
33+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package dmu.dasom.api.domain.interview.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.Max;
5+
import jakarta.validation.constraints.Min;
6+
import jakarta.validation.constraints.NotNull;
7+
import lombok.*;
8+
9+
import java.time.LocalDate;
10+
import java.time.LocalTime;
11+
12+
@Getter
13+
@Setter
14+
@NoArgsConstructor
15+
@AllArgsConstructor
16+
@Builder
17+
@Schema(name = "InterviewSlotRequestDto", description = "면접 슬롯 요청 DTO")
18+
public class InterviewSlotRequestDto {
19+
20+
@NotNull(message = "면접 날짜는 필수 입력 값입니다.")
21+
@Schema(description = "면접이 진행되는 날짜", example = "2025-03-12")
22+
private LocalDate interviewDate; // 면접 날짜
23+
24+
@NotNull(message = "시작 시간은 필수 입력 값입니다.")
25+
@Schema(description = "면접 시작 시간", example = "10:00")
26+
private LocalTime startTime; // 시작 시간
27+
28+
@NotNull(message = "종료 시간은 필수 입력 값입니다.")
29+
@Schema(description = "면접 종료 시간", example = "10:20")
30+
private LocalTime endTime; // 종료 시간
31+
32+
@NotNull(message = "최대 지원자 수는 필수 입력 값입니다.")
33+
@Min(value = 1, message = "최대 지원자 수는 최소 1명 이상이어야 합니다.")
34+
@Max(value = 100, message = "최대 지원자 수는 최대 100명까지 가능합니다.")
35+
@Schema(description = "해당 슬롯에서 허용되는 최대 지원자 수", example = "2")
36+
private Integer maxCandidates; // 최대 지원자 수
37+
}

0 commit comments

Comments
 (0)