-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/#14 #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/#14 #23
Changes from 6 commits
cb45f3f
887a6ba
1f446af
16fe512
c996772
1a03625
c33ae88
fa214ff
d5b3d16
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| NEIS_API_KEY=8ba18e6d2b1f42f9be762009bc3dfa90 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,8 @@ | ||
| 1 | ||
| /var/lib/postgresql/data | ||
| 1750235928 | ||
| 1751931356 | ||
| 5432 | ||
| /var/run/postgresql | ||
| * | ||
| 5 0 | ||
| 13 0 | ||
| ready |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,47 @@ | ||||||||||
| package hello.cluebackend.domain.timetable.presentation; | ||||||||||
|
|
||||||||||
| import hello.cluebackend.domain.timetable.presentation.dto.request.TimetableRequestDto; | ||||||||||
| import hello.cluebackend.domain.timetable.presentation.dto.response.TimetableResponseDto; | ||||||||||
| import hello.cluebackend.domain.timetable.service.TimetableService; | ||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||
| import org.springframework.http.ResponseEntity; | ||||||||||
| import org.springframework.web.bind.annotation.*; | ||||||||||
| import reactor.core.publisher.Mono; | ||||||||||
|
|
||||||||||
| import java.util.List; | ||||||||||
|
|
||||||||||
| @RestController | ||||||||||
| @RequestMapping("/api/timetable") | ||||||||||
| @RequiredArgsConstructor | ||||||||||
| public class TimetableController { | ||||||||||
| private final TimetableService timetableService; | ||||||||||
|
|
||||||||||
| @GetMapping("/test") | ||||||||||
| public String test(){ | ||||||||||
| return "Test"; | ||||||||||
| } | ||||||||||
|
||||||||||
| @GetMapping("/test") | |
| public String test(){ | |
| return "Test"; | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/TimetableController.java
around lines 19 to 22, there is a test endpoint mapped to "/test" returning a
simple string. This test endpoint should be removed entirely from the production
code to eliminate potential security risks. Delete the entire method annotated
with @GetMapping("/test") including its signature and body.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
컨트롤러 레벨 validation 추가 필요
요청 파라미터에 대한 검증이 없습니다. @Valid 어노테이션과 함께 적절한 검증을 추가해야 합니다.
@GetMapping("/today")
public Mono<ResponseEntity<List<TimetableResponseDto>>> getTodayTimetable(
- @RequestParam(required = true) String grade,
- @RequestParam(required = true) String classNumber
+ @RequestParam @Pattern(regexp = "^[1-3]$", message = "학년은 1~3 사이여야 합니다.") String grade,
+ @RequestParam @Pattern(regexp = "^[1-4]$", message = "반은 1~4 사이여야 합니다.") String classNumber
) {동일한 패턴을 /weekly 엔드포인트에도 적용하세요.
Also applies to: 37-40
🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/TimetableController.java
around lines 25-28 and 37-40, the request parameters lack validation. Add
validation annotations such as @Valid and appropriate constraints (e.g.,
@NotBlank, @NotNull) to the method parameters or wrap them in a validated DTO
class. Apply the same validation pattern to both the getTodayTimetable and the
/weekly endpoint methods to ensure request parameters are properly validated at
the controller level.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
에러 처리 개선 필요
현재 모든 에러를 500 Internal Server Error로 처리하고 있습니다. 에러 타입에 따라 적절한 HTTP 상태 코드를 반환해야 합니다.
- .onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().build()));
+ .onErrorResume(e -> {
+ if (e instanceof IllegalArgumentException) {
+ return Mono.just(ResponseEntity.badRequest().build());
+ }
+ return Mono.just(ResponseEntity.internalServerError().build());
+ });Also applies to: 45-45
🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/TimetableController.java
at lines 33 and 45, the error handling currently returns a generic 500 Internal
Server Error for all exceptions. Modify the onErrorResume handlers to inspect
the exception type and return appropriate HTTP status codes based on the error,
such as 404 for not found or 400 for bad requests, instead of always returning
500.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,28 @@ | ||||||||||||||||||||||||||||||||||
| package hello.cluebackend.domain.timetable.presentation.dto.request; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import hello.cluebackend.domain.timetable.service.TimetableService; | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DTO에서 서비스 클래스 import 제거 필요 DTO는 데이터 전송만 담당해야 하므로 서비스 레이어를 import하면 안 됩니다. 계층 간 의존성이 잘못되었습니다. -import hello.cluebackend.domain.timetable.service.TimetableService;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| import jakarta.validation.constraints.Max; | ||||||||||||||||||||||||||||||||||
| import jakarta.validation.constraints.Min; | ||||||||||||||||||||||||||||||||||
| import jakarta.validation.constraints.NotBlank; | ||||||||||||||||||||||||||||||||||
| import lombok.Data; | ||||||||||||||||||||||||||||||||||
| import lombok.Getter; | ||||||||||||||||||||||||||||||||||
| import lombok.Setter; | ||||||||||||||||||||||||||||||||||
| import org.hibernate.annotations.processing.Pattern; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @Getter | ||||||||||||||||||||||||||||||||||
| public class TimetableRequestDto { | ||||||||||||||||||||||||||||||||||
| @NotBlank(message = "학년은 필수입니다.") | ||||||||||||||||||||||||||||||||||
| @Min(value = 1) | ||||||||||||||||||||||||||||||||||
| @Max(value = 3) | ||||||||||||||||||||||||||||||||||
| private String grade; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @NotBlank(message = "반은 필수입니다.") | ||||||||||||||||||||||||||||||||||
| @Min(value = 1) | ||||||||||||||||||||||||||||||||||
| @Max(value = 4) | ||||||||||||||||||||||||||||||||||
| private String classNumber; | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+14
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. String 필드에 대한 잘못된 validation 수정 필요
- @NotBlank(message = "학년은 필수입니다.")
- @Min(value = 1)
- @Max(value = 3)
- private String grade;
-
- @NotBlank(message = "반은 필수입니다.")
- @Min(value = 1)
- @Max(value = 4)
- private String classNumber;
+ @NotBlank(message = "학년은 필수입니다.")
+ @Pattern(regexp = "^[1-3]$", message = "학년은 1~3 사이여야 합니다.")
+ private String grade;
+
+ @NotBlank(message = "반은 필수입니다.")
+ @Pattern(regexp = "^[1-4]$", message = "반은 1~4 사이여야 합니다.")
+ private String classNumber;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| public TimetableRequestDto(String grade, String classNumber) { | ||||||||||||||||||||||||||||||||||
| this.grade = grade; | ||||||||||||||||||||||||||||||||||
| this.classNumber = classNumber; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,43 @@ | ||||||||||||||
| package hello.cluebackend.domain.timetable.presentation.dto.response; | ||||||||||||||
|
|
||||||||||||||
| import lombok.Getter; | ||||||||||||||
| import lombok.NoArgsConstructor; | ||||||||||||||
| import lombok.Setter; | ||||||||||||||
|
|
||||||||||||||
| import java.time.DayOfWeek; | ||||||||||||||
| import java.time.LocalDate; | ||||||||||||||
| import java.time.format.DateTimeFormatter; | ||||||||||||||
| import java.time.format.TextStyle; | ||||||||||||||
| import java.util.Locale; | ||||||||||||||
| import java.util.Map; | ||||||||||||||
|
|
||||||||||||||
| @Getter @Setter | ||||||||||||||
| @NoArgsConstructor | ||||||||||||||
| public class TimetableResponseDto { | ||||||||||||||
|
|
||||||||||||||
| private String period; // 교시 | ||||||||||||||
| private String subject; // 과목 이름 | ||||||||||||||
| private String date; // 날짜 | ||||||||||||||
| private String dayOfWeek; // 요일 | ||||||||||||||
|
|
||||||||||||||
| public static TimetableResponseDto fromMap(Map<String, Object> map) { | ||||||||||||||
| TimetableResponseDto dto = new TimetableResponseDto(); | ||||||||||||||
|
|
||||||||||||||
| dto.period = (String) map.getOrDefault("PERIO", ""); | ||||||||||||||
| dto.subject = (String) map.getOrDefault("ITRT_CNTNT", ""); | ||||||||||||||
| dto.date = (String) map.getOrDefault("ALL_TI_YMD", ""); | ||||||||||||||
|
|
||||||||||||||
| if (!dto.date.isEmpty() && dto.date.length() == 8) { | ||||||||||||||
| try { | ||||||||||||||
| LocalDate localDate = LocalDate.parse(dto.date, DateTimeFormatter.ofPattern("yyyyMMdd")); | ||||||||||||||
| DayOfWeek dayOfWeek = localDate.getDayOfWeek(); | ||||||||||||||
| dto.dayOfWeek = dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN); | ||||||||||||||
| } catch (Exception e) { | ||||||||||||||
| dto.dayOfWeek = ""; | ||||||||||||||
| } | ||||||||||||||
| }else{ | ||||||||||||||
| dto.dayOfWeek="error"; | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+38
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 코드 포맷팅 및 에러 처리 개선 필요 else 블록의 포맷팅이 일관되지 않고, dayOfWeek에 "error" 문자열을 설정하는 것은 사용자 친화적이지 않습니다. - }else{
- dto.dayOfWeek="error";
- }
+ } else {
+ dto.dayOfWeek = "";
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| return dto; | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+23
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 입력 검증 및 예외 처리 개선 필요
public static TimetableResponseDto fromMap(Map<String, Object> map) {
+ if (map == null) {
+ return new TimetableResponseDto();
+ }
+
TimetableResponseDto dto = new TimetableResponseDto();
dto.period = (String) map.getOrDefault("PERIO", "");
dto.subject = (String) map.getOrDefault("ITRT_CNTNT", "");
dto.date = (String) map.getOrDefault("ALL_TI_YMD", "");
if (!dto.date.isEmpty() && dto.date.length() == 8) {
try {
LocalDate localDate = LocalDate.parse(dto.date, DateTimeFormatter.ofPattern("yyyyMMdd"));
DayOfWeek dayOfWeek = localDate.getDayOfWeek();
dto.dayOfWeek = dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN);
- } catch (Exception e) {
+ } catch (DateTimeParseException e) {
dto.dayOfWeek = "";
}
} else {
- dto.dayOfWeek = "";
+ dto.dayOfWeek = "";
}
return dto;
}
🤖 Prompt for AI Agents |
||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,176 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package hello.cluebackend.domain.timetable.service; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hello.cluebackend.domain.timetable.presentation.dto.request.TimetableRequestDto; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hello.cluebackend.domain.timetable.presentation.dto.response.TimetableResponseDto; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.beans.factory.annotation.Value; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.core.ParameterizedTypeReference; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.http.HttpStatusCode; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.reactive.function.client.WebClient; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.reactive.function.client.WebClientResponseException; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import reactor.core.publisher.Mono; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import reactor.util.retry.Retry; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.Duration; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.LocalDate; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.ZoneId; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.format.DateTimeFormatter; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Optional; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.stream.Collectors; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Slf4j | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Service | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class TimetableService { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Constants | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final String TIMETABLE_PATH = "/hisTimetable"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final DateTimeFormatter YEAR_FORMATTER = DateTimeFormatter.ofPattern("yyyy"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("MM"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final ZoneId KOREA_TIMEZONE = ZoneId.of("Asia/Seoul"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final int FIRST_SEMESTER_END_MONTH = 8; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final String FIRST_SEMESTER = "1"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final String SECOND_SEMESTER = "2"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final int API_TIMEOUT_SECONDS = 10; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final int RETRY_ATTEMPTS = 2; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final Duration RETRY_DELAY = Duration.ofSeconds(1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Value("${spring.neis.api.host}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String neisApiHost; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Value("${spring.neis.api.key}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String neisApiKey; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Value("${spring.neis.api.atpt-code:C10}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String atptCode; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Value("${spring.neis.api.school-code:7150658}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String schoolCode; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final WebClient webClient; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public Mono<List<TimetableResponseDto>> getTodayTimetable(TimetableRequestDto request) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| LocalDate today = LocalDate.now(KOREA_TIMEZONE); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String todayStr = today.format(DATE_FORMATTER); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.info("오늘 시간표 조회 요청 - Grade: {}, Class: {}, Date: {}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request.getGrade(), request.getClassNumber(), todayStr); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fetchTimetable(request, todayStr, todayStr); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+57
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 입력 검증 로직 추가 필요
각 메서드 시작 부분에 검증 로직을 추가하세요: public Mono<List<TimetableResponseDto>> getTodayTimetable(TimetableRequestDto request) {
if (request == null) {
return Mono.error(new IllegalArgumentException("요청 정보가 없습니다."));
}
LocalDate today = LocalDate.now(KOREA_TIMEZONE);
// ... 나머지 로직
}Also applies to: 67-76 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public Mono<List<TimetableResponseDto>> getWeeklyTimetable(TimetableRequestDto request) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| LocalDate now = LocalDate.now(KOREA_TIMEZONE); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String from = now.with(java.time.DayOfWeek.MONDAY).format(DATE_FORMATTER); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String to = now.with(java.time.DayOfWeek.FRIDAY).format(DATE_FORMATTER); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.info("주간 시간표 조회 요청 - Grade: {}, Class: {}, Period: {} ~ {}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request.getGrade(), request.getClassNumber(), from, to); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fetchTimetable(request, from, to); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private Mono<List<TimetableResponseDto>> fetchTimetable(TimetableRequestDto request, String from, String to) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| LocalDate now = LocalDate.now(KOREA_TIMEZONE); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String year = now.format(YEAR_FORMATTER); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String semester = determineSemester(now); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.debug("NEIS API 호출 파라미터 - Year: {}, Semester: {}, From: {}, To: {}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| year, semester, from, to); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return webClient.get() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .uri("https://" + neisApiHost + TIMETABLE_PATH + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "?KEY=" + neisApiKey + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&Type=json" + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&pIndex=1" + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&pSize=100" + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&ATPT_OFCDC_SC_CODE=" + atptCode + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&SD_SCHUL_CODE=" + schoolCode + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&AY=" + year + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&SEM=" + semester + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&GRADE=" + request.getGrade() + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&CLASS_NM=" + request.getClassNumber() + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&TI_FROM_YMD=" + from + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "&TI_TO_YMD=" + to) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+86
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 수동 URI 생성 대신 URI Builder 사용 필요 문자열 연결을 통한 URI 생성은 오류가 발생하기 쉽고 보안에 취약합니다. 주석으로 처리된 올바른 URI 빌더 방식을 사용해야 합니다. - .uri("https://" + neisApiHost + TIMETABLE_PATH +
- "?KEY=" + neisApiKey +
- "&Type=json" +
- "&pIndex=1" +
- "&pSize=100" +
- "&ATPT_OFCDC_SC_CODE=" + atptCode +
- "&SD_SCHUL_CODE=" + schoolCode +
- "&AY=" + year +
- "&SEM=" + semester +
- "&GRADE=" + request.getGrade() +
- "&CLASS_NM=" + request.getClassNumber() +
- "&TI_FROM_YMD=" + from +
- "&TI_TO_YMD=" + to)
+ .uri(uriBuilder -> uriBuilder
+ .scheme("https")
+ .host(neisApiHost)
+ .path(TIMETABLE_PATH)
+ .queryParam("KEY", neisApiKey)
+ .queryParam("Type", "json")
+ .queryParam("pIndex", "1")
+ .queryParam("pSize", "100")
+ .queryParam("ATPT_OFCDC_SC_CODE", atptCode)
+ .queryParam("SD_SCHUL_CODE", schoolCode)
+ .queryParam("AY", year)
+ .queryParam("SEM", semester)
+ .queryParam("GRADE", request.getGrade())
+ .queryParam("CLASS_NM", request.getClassNumber())
+ .queryParam("TI_FROM_YMD", from)
+ .queryParam("TI_TO_YMD", to)
+ .build())📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .uri(uriBuilder -> uriBuilder | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .scheme("https") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .host(neisApiHost) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .path(TIMETABLE_PATH) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("KEY", neisApiKey) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("Type", "json") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("pIndex", "1") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("pSize", "100") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("ATPT_OFCDC_SC_CODE", atptCode) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("SD_SCHUL_CODE", schoolCode) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("AY", year) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("SEM", semester) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("GRADE", request.getGrade()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("CLASS_NM", request.getClassNumber()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("TI_FROM_YMD", from) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .queryParam("TI_TO_YMD", to) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // .build()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .retrieve() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .onStatus(HttpStatusCode::is4xxClientError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| response -> response.bodyToMono(String.class) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .flatMap(body -> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.error("NEIS API 클라이언트 오류 - Status: {}, Body: {}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| response.statusCode(), body); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Mono.error(new IllegalArgumentException("잘못된 요청: " + body)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .onStatus(HttpStatusCode::is5xxServerError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| response -> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.error("NEIS API 서버 오류 - Status: {}", response.statusCode()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Mono.error(new RuntimeException("NEIS 서버 오류 발생")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .timeout(Duration.ofSeconds(API_TIMEOUT_SECONDS)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .retryWhen(Retry.backoff(RETRY_ATTEMPTS, RETRY_DELAY) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(throwable -> !(throwable instanceof WebClientResponseException.BadRequest)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.error("NEIS API 재시도 횟수 초과"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new RuntimeException("NEIS API 호출 실패 - 호출 시간 초과"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+132
to
+137
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 재시도 로직 개선 필요 현재 재시도 로직이 BadRequest만 제외하고 있지만, 다른 4xx 에러들도 재시도하면 안 됩니다. - .filter(throwable -> !(throwable instanceof WebClientResponseException.BadRequest))
+ .filter(throwable -> {
+ if (throwable instanceof WebClientResponseException) {
+ WebClientResponseException ex = (WebClientResponseException) throwable;
+ return !ex.getStatusCode().is4xxClientError();
+ }
+ return true;
+ })📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(this::parseTimetableResponse) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .doOnSuccess(result -> log.info("시간표 조회 성공 - 결과 개수: {}", result.size())) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .doOnError(error -> log.error("시간표 조회 실패", error)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String determineSemester(LocalDate date) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int month = Integer.parseInt(date.format(MONTH_FORMATTER)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return (month <= FIRST_SEMESTER_END_MONTH) ? FIRST_SEMESTER : SECOND_SEMESTER; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private List<TimetableResponseDto> parseTimetableResponse(Map<String, Object> response) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Optional.ofNullable(response.get("hisTimetable")) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(List.class::isInstance) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(obj -> (List<?>) obj) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(list -> list.size() >= 2) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(list -> list.get(1)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(Map.class::isInstance) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(obj -> (Map<?, ?>) obj) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(map -> map.get("row")) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(List.class::isInstance) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(obj -> (List<?>) obj) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(this::convertToTimetableResponseList) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .orElse(List.of()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (Exception e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.error("시간표 파싱 오류", e); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return List.of(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @SuppressWarnings("unchecked") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private List<TimetableResponseDto> convertToTimetableResponseList(List<?> rowList) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return rowList.stream() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(Map.class::isInstance) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(item -> (Map<String, Object>) item) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(TimetableResponseDto::fromMap) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .collect(Collectors.toList()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,13 @@ public RegisterUserService(UserRepository userRepository) { | |
| } | ||
|
|
||
| public void registerUser(DefaultRegisterUserDTO userDTO) { | ||
| UserEntity userEntity = new UserEntity(userDTO.getStudentId(), userDTO.getUsername(), userDTO.getStudentId()+userDTO.getUsername(), userDTO.getEmail(), userDTO.getRole()); | ||
| UserEntity userEntity = new UserEntity( | ||
| userDTO.getStudentId(), | ||
| userDTO.getUsername(), | ||
| userDTO.getStudentId()+userDTO.getUsername(), | ||
| userDTO.getEmail(), | ||
| userDTO.getRole() | ||
| ); | ||
|
Comment on lines
+17
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 평문(plain-text) 패스워드 저장은 심각한 보안 취약점입니다 - UserEntity userEntity = new UserEntity(
- userDTO.getStudentId(),
- userDTO.getUsername(),
- userDTO.getStudentId()+userDTO.getUsername(),
- userDTO.getEmail(),
- userDTO.getRole()
- );
+ // 예시: Spring Security PasswordEncoder 주입 후 사용
+ String encodedPassword = passwordEncoder.encode(userDTO.getPassword());
+ UserEntity userEntity = new UserEntity(
+ userDTO.getStudentId(),
+ userDTO.getUsername(),
+ encodedPassword,
+ userDTO.getEmail(),
+ userDTO.getRole()
+ );🤖 Prompt for AI Agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oauth2를 이용해서 비밀번호 따위는 존재하지 않음 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @fixgramwork OAuth2를 사용한다면 패스워드 저장이 필요 없다는 점을 이해했습니다. 하지만 현재 코드에서는 여전히 public void registerUser(DefaultRegisterUserDTO userDTO) {
UserEntity userEntity = new UserEntity(
userDTO.getStudentId(),
userDTO.getUsername(),
null, // OAuth2 사용으로 패스워드 불필요
userDTO.getEmail(),
userDTO.getRole()
);
userRepository.save(userEntity);
}또는 UserEntity 구조를 수정하여 패스워드 필드를 아예 제거하는 것이 더 명확할 것 같습니다. ✏️ Learnings added
|
||
| userRepository.save(userEntity); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,14 +74,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, RefreshTokenSe | |
| @Override | ||
| public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { | ||
| CorsConfiguration configuration = new CorsConfiguration(); | ||
|
|
||
| configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); | ||
| configuration.setAllowedOriginPatterns(Collections.singletonList("http://localhost:3000")); | ||
| configuration.setAllowedMethods(Collections.singletonList("*")); | ||
| configuration.setAllowCredentials(true); | ||
| configuration.setAllowedHeaders(Collections.singletonList("*")); | ||
| configuration.setMaxAge(3600L); | ||
| configuration.setExposedHeaders(Collections.singletonList("Authorization")); | ||
|
|
||
| configuration.setAllowCredentials(true); | ||
| configuration.setMaxAge(3600L); | ||
| return configuration; | ||
| } | ||
| })); | ||
|
|
@@ -116,12 +114,12 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { | |
| // 경로별 인가 작업 | ||
| http | ||
| .authorizeHttpRequests(auth -> auth | ||
| .requestMatchers("/", "refresh-token", "/register", "/first-register").permitAll() | ||
| .requestMatchers("/", "/refresh-token", "/register", "/first-register", "/api/timetable/**").permitAll() | ||
| .anyRequest().authenticated()); | ||
|
|
||
|
|
||
| http | ||
| .securityMatcher("/first-register", "/register") | ||
| .securityMatcher("/first-register", "/register","api/timetable/**") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain보안 매처 경로 패턴 불일치를 확인하세요.
다음 스크립트를 실행하여 애플리케이션의 다른 부분에서 일관된 경로 패턴을 사용하는지 확인하세요: 🏁 Script executed: #!/bin/bash
# 설명: 시간표 API 경로 패턴의 일관성을 확인합니다.
# 테스트: 프로젝트 내에서 시간표 API 경로 사용을 검색합니다.
echo "=== 시간표 API 경로 패턴 검색 ==="
rg -A 2 -B 2 "timetable" --type java
echo "=== 보안 매처 패턴 검색 ==="
rg -A 2 -B 2 "securityMatcher" --type javaLength of output: 7496 보안 매처 경로 패턴 수정 필요
변경 전: .securityMatcher("/first-register", "/register","api/timetable/**")변경 후: -.securityMatcher("/first-register", "/register","api/timetable/**")
+.securityMatcher("/first-register", "/register", "/api/timetable/**")🤖 Prompt for AI Agents |
||
| .sessionManagement(session -> | ||
| session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) | ||
| ) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
보안 경고: API 키가 버전 관리 시스템에 노출되었습니다.
NEIS API 키가
.env파일에 하드코딩되어 버전 관리 시스템에 커밋되었습니다. 이는 심각한 보안 위험을 초래할 수 있습니다.해결 방안:
.gitignore에 추가하여 향후 커밋을 방지🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NEIS_API_KEY 잘 쓸게요.
감사합니다 ㅎㅎ
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pdh0128 네, 알겠습니다! 🐰
API 키를 잘 활용하시길 바랍니다. 향후 프로덕션 환경에서는 보안을 위해 환경 변수나 시크릿 관리 시스템 사용을 고려해보시면 좋을 것 같습니다.
(´∀`)♡