From f506b124a33b1c0bea18b4bcddf27fa92f1540c6 Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 10:53:51 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=EC=BB=A4=EB=A6=AC=ED=81=98=EB=9F=BC=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=95=84=EC=88=98=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 3 +++ .../controller/CurriculumController.java | 5 ++-- .../curriculum/dto/CurriculumReqDTO.java | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/backend/build.gradle b/backend/build.gradle index d7f248a..29c74dc 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -41,6 +41,9 @@ dependencies { implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-database-postgresql' + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java index e53b441..8d17249 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java @@ -3,6 +3,7 @@ import com.example.Piroin.project.domain.curriculum.dto.CurriculumReqDTO; import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO; import com.example.Piroin.project.domain.curriculum.service.CurriculumService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -26,14 +27,14 @@ public ResponseEntity> getAllDays() { @PostMapping public ResponseEntity createDay( - @RequestBody CurriculumReqDTO.CreateDayReq req) { + @RequestBody @Valid CurriculumReqDTO.CreateDayReq req) { return ResponseEntity.status(HttpStatus.CREATED).body(curriculumService.createDay(req)); } @PatchMapping("/{sessionDate}") public ResponseEntity updateDay( @PathVariable LocalDate sessionDate, - @RequestBody CurriculumReqDTO.UpdateDayReq req) { + @RequestBody @Valid CurriculumReqDTO.UpdateDayReq req) { return ResponseEntity.ok(curriculumService.updateDay(sessionDate, req)); } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java index ceabfe1..631ef28 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java @@ -2,6 +2,9 @@ import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart; import com.example.Piroin.project.domain.curriculum.enums.SessionStatus; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,18 +16,32 @@ public class CurriculumReqDTO { @Getter @NoArgsConstructor public static class CreateDayReq { + @NotNull(message = "기수를 입력해주세요.") private Integer generation; + + @NotNull(message = "주차를 입력해주세요.") private Long week; + + @NotNull(message = "세션 날짜를 입력해주세요.") private LocalDate sessionDate; + + @NotEmpty(message = "세션 목록을 입력해주세요.") + @Valid private List sessions; } @Getter @NoArgsConstructor public static class SessionReq { + @NotNull(message = "세션 시간대를 입력해주세요.") private SessionDayPart dayPart; + + @NotNull(message = "세션 제목을 입력해주세요.") private String title; + + @NotNull(message = "발표자를 입력해주세요.") private String hostName; + private String sessionMaterialUrl; private String sessionMaterialName; private String recordingUrl; @@ -37,16 +54,25 @@ public static class SessionReq { @Getter @NoArgsConstructor public static class UpdateDayReq { + @NotNull(message = "기수를 입력해주세요.") private Integer generation; + + @NotNull(message = "주차를 입력해주세요.") private Long week; + private LocalDate newSessionDate; + + @NotEmpty(message = "세션 목록을 입력해주세요.") + @Valid private List sessions; } @Getter @NoArgsConstructor public static class UpdateSessionItemReq { + @NotNull(message = "세션 시간대를 입력해주세요.") private SessionDayPart dayPart; + private SessionStatus status; private String title; private String hostName; From a711043748dc584016e10c9984898c85baa97cc0 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 7 Jun 2026 10:51:53 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[Fix]=20=EC=9D=B4=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=84=A0=ED=83=9D=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/dto/QuestionResDTO.java | 2 ++ .../question/service/QuestionService.java | 24 +++++++++++++++---- frontend/src/pages/qna/QnAListPage.js | 18 ++++++++++---- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java index 37f18ba..ca5b1e8 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java @@ -145,6 +145,8 @@ public record UnderstandingCheckResponse( Integer understoodCount, // 오른쪽 X 뱃지 숫자 Integer notUnderstoodCount, + // 현재 로그인 유저가 누른 선택지. 누르지 않았거나 취소한 상태면 null + UnderstandResChoice selectedChoice, LocalDateTime createdAt ) { } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java index af58000..636222f 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java @@ -59,7 +59,7 @@ public QuestionResDTO.QuestionRoomResponse getQuestionRoom(Long sessionId, int u User loginUser = findLoginUser(userId); return new QuestionResDTO.QuestionRoomResponse( toSessionResponse(session), - getUnderstandingSlice(session, understandingIndex), + getUnderstandingSlice(session, understandingIndex, loginUser), getQuestionGroups(session, loginUser) ); } @@ -543,7 +543,11 @@ private QuestionResDTO.SessionResponse toSessionResponse(StudySession session) { ); } - private QuestionResDTO.UnderstandingSliceResponse getUnderstandingSlice(StudySession session, int understandingIndex) { + private QuestionResDTO.UnderstandingSliceResponse getUnderstandingSlice( + StudySession session, + int understandingIndex, + User loginUser + ) { Page understandingPage = understandingCheckRepository .findBySessionOrderByCreatedAtDesc(session, PageRequest.of(understandingIndex, UNDERSTANDING_PAGE_SIZE)); @@ -559,17 +563,17 @@ private QuestionResDTO.UnderstandingSliceResponse getUnderstandingSlice(StudySes // attendanceCount는 프론트 화면의 "13/29" 중 29에 해당한다. int attendanceCount = attendanceService.countAttendedBySession(session); return new QuestionResDTO.UnderstandingSliceResponse( - toUnderstandingCheckResponse(current, attendanceCount), understandingIndex, totalCount, + toUnderstandingCheckResponse(current, attendanceCount, loginUser), understandingIndex, totalCount, understandingIndex < totalCount - 1, understandingIndex > 0 ); } private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse(UnderstandingCheck check) { - return toUnderstandingCheckResponse(check, null); + return toUnderstandingCheckResponse(check, null, null); } private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse( - UnderstandingCheck check, Integer attendanceCount + UnderstandingCheck check, Integer attendanceCount, User loginUser ) { // understoodCount/notUnderstoodCount는 오른쪽 O/X 뱃지 숫자로 그대로 사용한다. int understoodCount = understandingResponseRepository.countByCheckAndChoice( @@ -585,10 +589,20 @@ private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse( attendanceCount, understoodCount, notUnderstoodCount, + getSelectedChoice(check, loginUser), check.getCreatedAt() ); } + private UnderstandResChoice getSelectedChoice(UnderstandingCheck check, User loginUser) { + if (loginUser == null) { + return null; + } + return understandingResponseRepository.findByCheckAndUser(check, loginUser) + .map(UnderstandingResponse::getChoice) + .orElse(null); + } + private QuestionResDTO.QuestionGroupsResponse getQuestionGroups(StudySession session, User loginUser) { List questions = questionRepository.findBySessionAndDeletedAtIsNull(session); QuestionSummaryContext summaryContext = getQuestionSummaryContext(questions, loginUser); diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 635d97c..81f3156 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -62,6 +62,13 @@ function QnAListPage() { setSessionTitle(`${session.week}주차 ${DAY_OF_WEEK_KO[session.dayOfWeek]}요일 ${DAY_PART_KO[session.dayPart]} (${session.title})`); setUnderstanding(understanding); + const currentCheck = understanding?.current; + if (currentCheck?.checkId) { + setMyChoices(prev => ({ + ...prev, + [currentCheck.checkId]: currentCheck.selectedChoice ?? null, + })); + } const allQ = [ ...(questions.popularQuestions ?? []), @@ -116,17 +123,18 @@ function QnAListPage() { const handleUnderstandChoice = async (choice) => { if (!understanding?.current?.checkId) return; const checkId = understanding.current.checkId; - const newChoice = myChoices[checkId] === choice ? null : choice; + const previousChoice = myChoices[checkId] ?? null; + const newChoice = previousChoice === choice ? null : choice; setMyChoices(prev => ({ ...prev, [checkId]: newChoice })); - if (!newChoice) return; try { const res = await authFetch( `/api/sessions/${sessionId}/understanding-checks/${checkId}/responses`, - { method: 'POST', body: JSON.stringify({ choice: newChoice }) } + { method: 'POST', body: JSON.stringify({ choice }) } ); if (!res.ok) throw new Error(); const json = await res.json(); if (json.isSuccess) { + setMyChoices(prev => ({ ...prev, [checkId]: json.result.selectedChoice ?? null })); setUnderstanding(prev => ({ ...prev, current: { @@ -135,10 +143,12 @@ function QnAListPage() { notUnderstoodCount: json.result.notUnderstoodCount, attendanceCount: json.result.attendanceCount, respondedCount: json.result.respondedCount, + selectedChoice: json.result.selectedChoice, } })); } } catch (err) { + setMyChoices(prev => ({ ...prev, [checkId]: previousChoice })); console.error('이해도 응답 실패:', err); } }; @@ -593,4 +603,4 @@ function QnAListPage() { ); } -export default QnAListPage; \ No newline at end of file +export default QnAListPage; From 6adf55e07d504f541b2d29983c71459679d18aab Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 7 Jun 2026 10:59:21 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[Refactor]=20json.isSuccess=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnAListPage.js | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 81f3156..ef9f10d 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -133,20 +133,19 @@ function QnAListPage() { ); if (!res.ok) throw new Error(); const json = await res.json(); - if (json.isSuccess) { - setMyChoices(prev => ({ ...prev, [checkId]: json.result.selectedChoice ?? null })); - setUnderstanding(prev => ({ - ...prev, - current: { - ...prev.current, - understoodCount: json.result.understoodCount, - notUnderstoodCount: json.result.notUnderstoodCount, - attendanceCount: json.result.attendanceCount, - respondedCount: json.result.respondedCount, - selectedChoice: json.result.selectedChoice, - } - })); - } + if (!json.isSuccess) throw new Error(json.message); + setMyChoices(prev => ({ ...prev, [checkId]: json.result.selectedChoice ?? null })); + setUnderstanding(prev => ({ + ...prev, + current: { + ...prev.current, + understoodCount: json.result.understoodCount, + notUnderstoodCount: json.result.notUnderstoodCount, + attendanceCount: json.result.attendanceCount, + respondedCount: json.result.respondedCount, + selectedChoice: json.result.selectedChoice, + } + })); } catch (err) { setMyChoices(prev => ({ ...prev, [checkId]: previousChoice })); console.error('이해도 응답 실패:', err); From a0fda66207a5d5ac4f31edeaf00d1a1d076372ad Mon Sep 17 00:00:00 2001 From: kkw610 Date: Sun, 7 Jun 2026 10:59:30 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20=EC=A7=88=EB=AC=B8/=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=8B=A8=EB=8F=85=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B6=88=EA=B0=80=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/entity/Question.java | 2 +- .../question/entity/QuestionComment.java | 2 +- .../question/service/QuestionService.java | 24 +++++++++++++++++++ .../V7__alter_question_content_nullable.sql | 3 +++ 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V7__alter_question_content_nullable.sql diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java index a9a4a0d..195f34c 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java @@ -27,7 +27,7 @@ public class Question { @JoinColumn(name = "user_id", nullable = false) private User user; - @Column(nullable = false, columnDefinition = "TEXT") + @Column(columnDefinition = "TEXT") private String content; @Column(name = "image_url", columnDefinition = "TEXT") diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java index c173dd9..d91606c 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java @@ -36,7 +36,7 @@ public class QuestionComment { @JoinColumn(name = "parent_comment_id") private QuestionComment parentComment; - @Column(nullable = false, columnDefinition = "TEXT") + @Column(columnDefinition = "TEXT") private String content; @Column(name = "image_url", columnDefinition = "TEXT") diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java index 636222f..a3d0037 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java @@ -136,6 +136,9 @@ public QuestionResDTO.CommentCreateRes createComment( // 1. 대댓글 여부 확인: parentCommentId가 있으면 부모 댓글 조회 QuestionComment parentComment = resolveParentComment(request.getParentCommentId(), question); + // builder 전에 검증 추가 + validateCommentContent(request.getContent(), request.getImageUrl()); + // 2. 댓글 엔티티 생성 및 저장 LocalDateTime now = LocalDateTime.now(); QuestionComment comment = QuestionComment.builder() @@ -248,6 +251,9 @@ public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.Cr User loginUser = findLoginUser(userId); StudySession session = findSession(sessionId); + // builder 전에 검증 추가 + validateQuestionContent(request.getContent(), request.getImageUrl()); + Question question = Question.builder() .session(session) .user(loginUser) @@ -807,4 +813,22 @@ private record QuestionSummaryContext( Set likedQuestionIds ) { } + + // 질문은 내용 또는 이미지 중 하나는 반드시 있어야 함 + private void validateQuestionContent(String content, String imageUrl) { + boolean hasContent = content != null && !content.isBlank(); + boolean hasImage = imageUrl != null && !imageUrl.isBlank(); + if (!hasContent && !hasImage) { + throw new QuestionException(HttpStatus.BAD_REQUEST, "질문 내용 또는 이미지 중 하나는 필수입니다."); + } + } + + // 댓글은 내용 또는 이미지 중 하나는 반드시 있어야 함 + private void validateCommentContent(String content, String imageUrl) { + boolean hasContent = content != null && !content.isBlank(); + boolean hasImage = imageUrl != null && !imageUrl.isBlank(); + if (!hasContent && !hasImage) { + throw new QuestionException(HttpStatus.BAD_REQUEST, "댓글 내용 또는 이미지 중 하나는 필수입니다."); + } + } } diff --git a/backend/src/main/resources/db/migration/V7__alter_question_content_nullable.sql b/backend/src/main/resources/db/migration/V7__alter_question_content_nullable.sql new file mode 100644 index 0000000..86b1f40 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__alter_question_content_nullable.sql @@ -0,0 +1,3 @@ +-- 이미지만 단독 업로드가 가능하도록 content NOT NULL 제약 해제 +ALTER TABLE question ALTER COLUMN content DROP NOT NULL; +ALTER TABLE question_comment ALTER COLUMN content DROP NOT NULL; \ No newline at end of file From 9c3b5a091ae717ae7698f40c8339d4160521f66d Mon Sep 17 00:00:00 2001 From: lilyyang0077 Date: Sun, 7 Jun 2026 10:44:07 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=9E=99=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.js | 36 +++++++++++++++----------- frontend/src/routes/ProtectedRoute.jsx | 11 ++++++++ 2 files changed, 32 insertions(+), 15 deletions(-) create mode 100644 frontend/src/routes/ProtectedRoute.jsx diff --git a/frontend/src/App.js b/frontend/src/App.js index bc1358b..60aa4a8 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -12,6 +12,7 @@ import Assignment from './pages/pirocheck/assignment/Assignment'; import Deposit from './pages/pirocheck/deposit/Deposit'; import StudentList from './pages/pirocheck/students/StudentList'; import StudentDetail from './pages/pirocheck/students/StudentDetail'; +import ProtectedRoute from "./routes/ProtectedRoute"; function App() { return ( @@ -23,22 +24,27 @@ function App() { } /> } /> - {/* 라이트 헤더 페이지 */} - }> - } /> - } /> - } /> - } /> - + {/* 로그인 필요한 페이지들 */} + }> + + {/* 라이트 헤더 페이지 */} + }> + } /> + } /> + } /> + } /> + + + {/* 다크 헤더 페이지 */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* 다크 헤더 페이지 */} - }> - } /> - } /> - } /> - } /> - } /> - } /> diff --git a/frontend/src/routes/ProtectedRoute.jsx b/frontend/src/routes/ProtectedRoute.jsx new file mode 100644 index 0000000..37d47d8 --- /dev/null +++ b/frontend/src/routes/ProtectedRoute.jsx @@ -0,0 +1,11 @@ +import { Navigate, Outlet } from "react-router-dom"; + +export default function ProtectedRoute() { + const token = localStorage.getItem("token"); + + if (!token) { + return ; + } + + return ; +} \ No newline at end of file From ea7202cfd32b7dee15353a8b30ac4682d847538b Mon Sep 17 00:00:00 2001 From: lilyyang0077 Date: Sun, 7 Jun 2026 11:01:54 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EB=A1=9C=EA=B3=A0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/public/index.html | 4 ++-- frontend/public/manifest.json | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/public/index.html b/frontend/public/index.html index c504689..cf3e89a 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -9,7 +9,7 @@ name="description" content="Web site created using create-react-app" /> - +