Skip to content
Closed
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
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NEIS_API_KEY=8ba18e6d2b1f42f9be762009bc3dfa90
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'

implementation 'org.projectlombok:lombok:1.18.30'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'io.projectreactor:reactor-test'
annotationProcessor 'org.projectlombok:lombok:1.18.30'

developmentOnly 'org.springframework.boot:spring-boot-devtools'
Expand All @@ -53,6 +56,9 @@ dependencies {

asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

testImplementation 'org.mockito:mockito-core'
testImplementation 'org.mockito:mockito-junit-jupiter'
}

ext {
Expand Down
Binary file modified postgres-data/base/1/1259
Binary file not shown.
Binary file modified postgres-data/base/1/13398
Binary file not shown.
Binary file modified postgres-data/base/1/2619
Binary file not shown.
Binary file modified postgres-data/base/1/2619_fsm
Binary file not shown.
Binary file modified postgres-data/base/1/2619_vm
Binary file not shown.
Binary file modified postgres-data/base/1/2696
Binary file not shown.
Binary file modified postgres-data/base/1/2840
Binary file not shown.
Binary file modified postgres-data/base/1/2840_fsm
Binary file not shown.
Binary file modified postgres-data/base/1/2840_vm
Binary file not shown.
Binary file modified postgres-data/base/1/2841
Binary file not shown.
Binary file modified postgres-data/base/16384/1247
Binary file not shown.
Binary file modified postgres-data/base/16384/1249
Binary file not shown.
Binary file modified postgres-data/base/16384/1259
Binary file not shown.
Binary file modified postgres-data/base/16384/13398
Binary file not shown.
Binary file not shown.
File renamed without changes.
Binary file modified postgres-data/base/16384/16390
Binary file not shown.
Binary file not shown.
Binary file added postgres-data/base/16384/16392
Binary file not shown.
Binary file removed postgres-data/base/16384/16396
Binary file not shown.
Binary file modified postgres-data/base/16384/2224
Binary file not shown.
Binary file modified postgres-data/base/16384/2579
Binary file not shown.
Binary file modified postgres-data/base/16384/2604
Binary file not shown.
Binary file modified postgres-data/base/16384/2606
Binary file not shown.
Binary file modified postgres-data/base/16384/2608
Binary file not shown.
Binary file modified postgres-data/base/16384/2610
Binary file not shown.
Binary file modified postgres-data/base/16384/2619
Binary file not shown.
Binary file modified postgres-data/base/16384/2656
Binary file not shown.
Binary file modified postgres-data/base/16384/2657
Binary file not shown.
Binary file modified postgres-data/base/16384/2658
Binary file not shown.
Binary file modified postgres-data/base/16384/2659
Binary file not shown.
Binary file modified postgres-data/base/16384/2662
Binary file not shown.
Binary file modified postgres-data/base/16384/2663
Binary file not shown.
Binary file modified postgres-data/base/16384/2664
Binary file not shown.
Binary file modified postgres-data/base/16384/2665
Binary file not shown.
Binary file modified postgres-data/base/16384/2666
Binary file not shown.
Binary file modified postgres-data/base/16384/2667
Binary file not shown.
Binary file modified postgres-data/base/16384/2673
Binary file not shown.
Binary file modified postgres-data/base/16384/2674
Binary file not shown.
Binary file modified postgres-data/base/16384/2678
Binary file not shown.
Binary file modified postgres-data/base/16384/2679
Binary file not shown.
Binary file modified postgres-data/base/16384/2703
Binary file not shown.
Binary file modified postgres-data/base/16384/2704
Binary file not shown.
Binary file modified postgres-data/base/16384/3455
Binary file not shown.
Binary file modified postgres-data/base/16384/5002
Binary file not shown.
Binary file modified postgres-data/base/16384/pg_internal.init
Binary file not shown.
Binary file modified postgres-data/base/4/13398
Binary file not shown.
Binary file modified postgres-data/base/4/2619
Binary file not shown.
Binary file modified postgres-data/base/5/13398
Binary file not shown.
Binary file modified postgres-data/base/5/2619
Binary file not shown.
Binary file modified postgres-data/global/1260
Binary file not shown.
Binary file modified postgres-data/global/pg_control
Binary file not shown.
Binary file modified postgres-data/global/pg_internal.init
Binary file not shown.
Binary file modified postgres-data/pg_wal/000000010000000000000001
Binary file not shown.
Binary file modified postgres-data/pg_xact/0000
Binary file not shown.
4 changes: 2 additions & 2 deletions postgres-data/postmaster.pid
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("/today")
public Mono<ResponseEntity<List<TimetableResponseDto>>> getTodayTimetable(
@RequestParam(required = true) String grade,
@RequestParam(required = true) String classNumber
) {
TimetableRequestDto request = new TimetableRequestDto(grade, classNumber);
return timetableService.getTodayTimetable(request)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.noContent().build())
.onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().build()));
}

@GetMapping("/weekly")
public Mono<ResponseEntity<List<TimetableResponseDto>>> getWeeklyTimetable(
@RequestParam(required = true) String grade,
@RequestParam(required = true) String classNumber
) {
TimetableRequestDto request = new TimetableRequestDto(grade, classNumber);
return timetableService.getWeeklyTimetable(request)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.noContent().build())
.onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().build()));
}
}
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;
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;

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";
}
return dto;
}
}
Copy link
Member Author

Choose a reason for hiding this comment

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

추후 리팩토링 과정에서 하드 코딩된 url 전송을 queryParam으로 고칠 예정,
redis를 이용해서 데이터 캐싱하여, 외부 API 접근 횟수 줄일 예정

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);
}

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)
// .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 호출 실패 - 호출 시간 초과");
}))
.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
Expand Up @@ -8,7 +8,6 @@

@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {

Optional<UserEntity> findByUsername(String username);

Optional<UserEntity> findByEmail(String email);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

@RestController
public class RegisterController {

private final RegisterUserService registerUserService;

public RegisterController(RegisterUserService registerUserService) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
userRepository.save(userEntity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}));
Expand Down Expand Up @@ -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/**")
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
Expand Down
Loading