diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/User/exception/GlobalExceptionHandler.java b/backend/pirocheck/src/main/java/backend/pirocheck/User/exception/GlobalExceptionHandler.java index a135034..4fd3755 100644 --- a/backend/pirocheck/src/main/java/backend/pirocheck/User/exception/GlobalExceptionHandler.java +++ b/backend/pirocheck/src/main/java/backend/pirocheck/User/exception/GlobalExceptionHandler.java @@ -9,6 +9,7 @@ @RestControllerAdvice public class GlobalExceptionHandler { + // InvalidLoginException (로그인 실패) @ExceptionHandler(InvalidLoginException.class) public ResponseEntity> handleInvalidLoginException(InvalidLoginException e) { return ResponseEntity diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/User/filter/SessionCheckFilter.java b/backend/pirocheck/src/main/java/backend/pirocheck/User/filter/SessionCheckFilter.java new file mode 100644 index 0000000..61a09c4 --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/User/filter/SessionCheckFilter.java @@ -0,0 +1,40 @@ +package backend.pirocheck.User.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class SessionCheckFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String path = request.getRequestURI(); + + // 로그인/로그아웃 요청은 세션 체크 제외 + if (path.startsWith("/api/login") || path.startsWith("/api/logout")) { + filterChain.doFilter(request, response); // 다음 필터나 컨트롤러로 넘기는 명령어 + return; // 세션 검사 안함 + } + + HttpSession session = request.getSession(false); // 세션이 없으면 새로 만들지 않고 null을 리턴 (true : 새로 생성) + + if (session == null || session.getAttribute("loginUser") == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 설정 + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"success\":false,\"message\":\"세션이 만료되었습니다.\",\"data\":null}"); + return; + } + + filterChain.doFilter(request, response); + + } +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/User/service/UserService.java b/backend/pirocheck/src/main/java/backend/pirocheck/User/service/UserService.java index 04231cf..ff5fb5d 100644 --- a/backend/pirocheck/src/main/java/backend/pirocheck/User/service/UserService.java +++ b/backend/pirocheck/src/main/java/backend/pirocheck/User/service/UserService.java @@ -14,10 +14,10 @@ public class UserService { public User login(String name, String password) { User user = userRepository.findByName(name) - .orElseThrow(() -> new InvalidLoginException("해당 사용자가 존재하지 않습니다.")); + .orElseThrow(() -> new InvalidLoginException("해당 사용자가 존재하지 않습니다.")); //401 if (!user.getPassword().equals(password)) { - throw new InvalidLoginException("비밀번호가 일치하지 않습니다."); + throw new InvalidLoginException("비밀번호가 일치하지 않습니다."); //401 } return user; diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/config/SessionCheckFilterConfig.java b/backend/pirocheck/src/main/java/backend/pirocheck/config/SessionCheckFilterConfig.java new file mode 100644 index 0000000..06e1edf --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/config/SessionCheckFilterConfig.java @@ -0,0 +1,19 @@ +package backend.pirocheck.config; + +import backend.pirocheck.User.filter.SessionCheckFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SessionCheckFilterConfig { + + @Bean + public FilterRegistrationBean sessionCheckFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new SessionCheckFilter()); + registrationBean.addUrlPatterns("/api/*"); + registrationBean.setOrder(1); + return registrationBean; + } +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/config/WebConfig.java b/backend/pirocheck/src/main/java/backend/pirocheck/config/WebConfig.java new file mode 100644 index 0000000..a6ae980 --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/config/WebConfig.java @@ -0,0 +1,17 @@ +package backend.pirocheck.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") // 백엔드 API 요청에만 CORS 허용 + .allowedOrigins("http://pirocheck.org:3000") // 프론트 배포 URL + .allowedMethods("GET", "POST", "PUT", "DELETE") // 허용할 HTTP 메서드 + .allowCredentials(true); // 세션 쿠키 주고받기 허용 + } +} diff --git a/backend/pirocheck/src/main/resources/application.yml b/backend/pirocheck/src/main/resources/application.yml index 1416bb4..b8b7096 100644 --- a/backend/pirocheck/src/main/resources/application.yml +++ b/backend/pirocheck/src/main/resources/application.yml @@ -11,4 +11,13 @@ spring: properties: hibernate: format_sql: true - open-in-view: false \ No newline at end of file + open-in-view: false +server: + servlet: + session: + cookie: + http-only: true # 세션 쿠키를 HttpOnly로 설정 (JS에서 접근 불가) + secure: false # HTTPS 전용 전송 (Https -> true로 바꿔야 함) + same-site: Lax # CSRF 방지 + timeout: 30m # 세션 타임아웃 30분 (30 minutes) + address: 0.0.0.0 \ No newline at end of file diff --git a/frontend/public/assets/img/boom-fill-green.png b/frontend/public/assets/img/boom-fill-green.png new file mode 100644 index 0000000..6416093 Binary files /dev/null and b/frontend/public/assets/img/boom-fill-green.png differ diff --git a/frontend/public/assets/img/boom-fill-red.png b/frontend/public/assets/img/boom-fill-red.png new file mode 100644 index 0000000..ef5bb3b Binary files /dev/null and b/frontend/public/assets/img/boom-fill-red.png differ diff --git a/frontend/src/Attendance.jsx b/frontend/src/Attendance.jsx index 4f1cbef..cff814f 100644 --- a/frontend/src/Attendance.jsx +++ b/frontend/src/Attendance.jsx @@ -8,6 +8,11 @@ import axios from "axios"; const Attendance = () => { const [attendanceCode, setAttendanceCode] = useState([""]); const [attendanceData, setAttendanceData] = useState([]); + const [todayStatuses, setTodayStatuses] = useState([ + "not_started", + "not_started", + "not_started", + ]); const getSubImage = (count) => { switch (count) { @@ -22,6 +27,18 @@ const Attendance = () => { } }; + // 세션별 상단 이미지 handling + const getBoomImage = (status) => { + switch (status) { + case "success": + return "/assets/img/boom-fill-green.png"; + case "fail": + return "/assets/img/boom-fill-red.png"; + default: + return "/assets/img/tabler--boom.png"; + } + }; + // 날짜 기반 주차 계산 const getWeekFromDate = (dateStr) => { const startDate = new Date("2025-06-24"); // 세션 시작일 @@ -64,26 +81,57 @@ const Attendance = () => { }); }; - useEffect(() => { - const fetchAttendance = async () => { - try { - const user = JSON.parse(localStorage.getItem("user")); - const userId = user?.id; - - if (!userId) return; - - const res = await axios.get(`/api/attendance/user`, { - params: { userId }, - }); - - const rawData = res.data.data; - const weekly = processWeeklyAttendance(rawData); - setAttendanceData(weekly); - } catch (error) { - console.error("출석 정보 가져오기 실패:", error); + const fetchAttendance = async () => { + try { + const user = JSON.parse(localStorage.getItem("user")); + const userId = user?.id; + if (!userId) return; + + // 유저 전체 출석 데이터 불러오기 + const res = await axios.get(`/api/attendance/user`, { + params: { userId }, + }); + const rawData = res.data.data; + const weekly = processWeeklyAttendance(rawData); + setAttendanceData(weekly); + } catch (error) { + console.error("출석 정보 가져오기 실패:", error); + } + }; + + // 세션별 출석체크(총 3번) 진행 정보 불러오기 + const fetchTodayAttendance = async () => { + try { + const user = JSON.parse(localStorage.getItem("user")); + const userId = user?.id; + if (!userId) return; + + const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD + const res = await axios.get(`/api/attendance/user/date`, { + params: { userId, date: today }, + }); + + const slots = res.data.data?.[0]?.slots || []; + + const statuses = slots.map((slot) => { + if (slot.status === true) return "success"; + else return "fail"; + }); + + // 출석체크 진행안된 것 처리 + while (statuses.length < 3) { + statuses.push("not_started"); } - }; + + setTodayStatuses(statuses); + } catch (error) { + console.error("오늘 출석 정보 가져오기 실패:", error); + } + }; + + useEffect(() => { fetchAttendance(); + fetchTodayAttendance(); }, []); const handleChange = (index, value) => { @@ -100,7 +148,7 @@ const Attendance = () => { const userId = user?.id; if (!userId) return; - // 출석체크 서버에 반영 + // 유저가 입력한 출석 코드 서버에 전달(서버에서 출석코드 체크) const res = await axios.post("/api/attendance/mark", { userId, code: attendanceCode[0], @@ -109,6 +157,7 @@ const Attendance = () => { if (res.data.success) { alert("출석이 성공적으로 처리되었습니다!"); fetchAttendance(); // 서버 출석체크 전달 후 UI 반영 + fetchTodayAttendance(); // 세션별 상단 이미지 UI 반영 } else { alert(res.data.message); } @@ -137,15 +186,11 @@ const Attendance = () => { )}
-
- -
-
- -
-
- -
+ {todayStatuses.map((status, idx) => ( +
+ {`attendance-${idx}`} +
+ ))}
{attendanceData.map(({ week, classes }) => (