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; diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java index 3cd0b8a..97952d3 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java @@ -35,8 +35,12 @@ public ResponseEntity> getQuest // text/event-stream으로 연결을 유지하며, 댓글 생성 같은 목록 갱신 이벤트를 받는다. // 인증 헤더가 필요하므로 프론트에서는 기본 EventSource 대신 fetch 기반 SSE 클라이언트로 구독한다. @GetMapping(value = "/api/sessions/{sessionId}/questions/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter subscribeQuestionEvents(@PathVariable Long sessionId) { - return questionService.subscribeQuestionEvents(sessionId); + public ResponseEntity subscribeQuestionEvents(@PathVariable Long sessionId) { + return ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .header("Cache-Control", "no-cache") + .header("X-Accel-Buffering", "no") + .body(questionService.subscribeQuestionEvents(sessionId)); } // 질문 상세 조회 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..fd59783 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 ) { } @@ -195,6 +197,17 @@ public record CommentCreatedEvent( ) { } + // 댓글 수정/삭제 시 SSE로 내려가는 목록 갱신 이벤트 응답 + public record CommentUpdatedEvent( + String type, + Long sessionId, + Long questionId, + Boolean isResolved, + Integer commentCount, + List previewComments + ) { + } + // O/X 클릭 직후 응답. selectedChoice가 null이면 같은 선택지를 다시 눌러 취소된 상태다. public record UnderstandingResponseResult( Long checkId, @@ -241,6 +254,19 @@ public record QuestionCreatedEvent( ) { } + // 좋아요, 해결 상태, 본문 수정, 삭제처럼 기존 질문의 상태가 바뀔 때 내려가는 SSE 이벤트 + public record QuestionUpdatedEvent( + String type, + Long sessionId, + Long questionId, + String content, + Boolean isResolved, + Integer likeCount, + Boolean isDeleted, + LocalDateTime updatedAt + ) { + } + // 운영진이 이해도 체크를 생성했을 때 SSE로 내려가는 이벤트. // 같은 세션 질문방을 보고 있는 모든 클라이언트에게 전파된다. public record UnderstandingCheckCreatedEvent( 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/QuestionEventService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java index 087eac4..2ba72d2 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java @@ -45,11 +45,21 @@ public void publishCommentCreated(Long sessionId, QuestionResDTO.CommentCreatedE broadcast(sessionId, "comment-created", event); } + // 댓글 수정/삭제 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. + public void publishCommentUpdated(Long sessionId, QuestionResDTO.CommentUpdatedEvent event) { + broadcast(sessionId, "comment-updated", event); + } + // 질문 등록 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. public void publishQuestionCreated(Long sessionId, QuestionResDTO.QuestionCreatedEvent event) { broadcast(sessionId, "question-created", event); } + // 질문 상태 변경 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. + public void publishQuestionUpdated(Long sessionId, QuestionResDTO.QuestionUpdatedEvent event) { + broadcast(sessionId, "question-updated", event); + } + // 이해도 체크 생성 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. public void publishUnderstandingCheckCreated(Long sessionId, QuestionResDTO.UnderstandingCheckCreatedEvent event) { broadcast(sessionId, "understanding-check-created", event); 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..74552f7 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) ); } @@ -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) @@ -275,7 +281,7 @@ public QuestionResDTO.LikeRes toggleLike(Long questionId, Long userId) { Question question = findQuestion(questionId); // 이미 좋아요를 눌렀는지 확인 - return questionLikeRepository.findByQuestionAndUser(question, loginUser) + QuestionResDTO.LikeRes result = questionLikeRepository.findByQuestionAndUser(question, loginUser) .map(existingLike -> { // 이미 눌렀으면 → 취소 (삭제 + likeCount -1) questionLikeRepository.delete(existingLike); @@ -292,6 +298,9 @@ public QuestionResDTO.LikeRes toggleLike(Long questionId, Long userId) { question.increaseLikeCount(); return new QuestionResDTO.LikeRes(question.getId(), question.getLikeCount(), true); }); + + publishQuestionUpdatedEventAfterCommit(question, false); + return result; } // 질문 수정 @@ -307,6 +316,8 @@ public QuestionResDTO.UpdateDeleteRes updateQuestion( question.updateContent(request.getContent()); + publishQuestionUpdatedEventAfterCommit(question, false); + return new QuestionResDTO.UpdateDeleteRes( question.getId(), question.getContent(), question.getUpdatedAt(), question.getDeletedAt() @@ -322,6 +333,8 @@ public QuestionResDTO.UpdateDeleteRes deleteQuestion(Long questionId, Long userI question.softDelete(); + publishQuestionUpdatedEventAfterCommit(question, true); + return new QuestionResDTO.UpdateDeleteRes( question.getId(), question.getContent(), question.getUpdatedAt(), question.getDeletedAt() @@ -341,6 +354,8 @@ public QuestionResDTO.CommentUpdateDeleteRes updateComment( comment.updateContent(request.getContent()); + publishCommentUpdatedEventAfterCommit(comment.getQuestion()); + return new QuestionResDTO.CommentUpdateDeleteRes( comment.getId(), comment.getContent(), comment.getUpdatedAt(), comment.getDeletedAt() @@ -356,6 +371,8 @@ public QuestionResDTO.CommentUpdateDeleteRes deleteComment(Long commentId, Long comment.softDelete(); + publishCommentUpdatedEventAfterCommit(comment.getQuestion()); + return new QuestionResDTO.CommentUpdateDeleteRes( comment.getId(), comment.getContent(), comment.getUpdatedAt(), comment.getDeletedAt() @@ -373,6 +390,8 @@ public QuestionResDTO.StatusUpdateRes updateQuestionStatus(Long questionId, Long Question question = findQuestion(questionId); question.markResolved(); + publishQuestionUpdatedEventAfterCommit(question, false); + return new QuestionResDTO.StatusUpdateRes( question.getId(), question.getIsResolved(), question.getUpdatedAt() ); @@ -543,7 +562,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 +582,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 +608,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); @@ -722,6 +755,32 @@ private void publishCommentCreatedEventAfterCommit(Question question) { publishAfterCommit(() -> questionEventService.publishCommentCreated(sessionId, event)); } + private void publishCommentUpdatedEventAfterCommit(Question question) { + Long sessionId = question.getSession().getId(); + Long questionId = question.getId(); + List questionIds = List.of(questionId); + + Map commentCounts = new HashMap<>(); + questionCommentRepository.countByQuestionIds(questionIds) + .forEach(row -> commentCounts.put(row.getQuestionId(), Math.toIntExact(row.getCommentCount()))); + + Map> previewComments = new HashMap<>(); + questionCommentRepository.findPreviewCommentsByQuestionIds(questionIds) + .forEach(row -> previewComments.computeIfAbsent(row.getQuestionId(), key -> new ArrayList<>()) + .add(toPreviewCommentResponse(question, row))); + + QuestionResDTO.CommentUpdatedEvent event = new QuestionResDTO.CommentUpdatedEvent( + "COMMENT_UPDATED", + sessionId, + questionId, + question.getIsResolved(), + commentCounts.getOrDefault(questionId, 0), + previewComments.getOrDefault(questionId, List.of()) + ); + + publishAfterCommit(() -> questionEventService.publishCommentUpdated(sessionId, event)); + } + private void publishQuestionCreatedEventAfterCommit(Question question) { Long sessionId = question.getSession().getId(); @@ -739,6 +798,23 @@ private void publishQuestionCreatedEventAfterCommit(Question question) { publishAfterCommit(() -> questionEventService.publishQuestionCreated(sessionId, event)); } + private void publishQuestionUpdatedEventAfterCommit(Question question, boolean isDeleted) { + Long sessionId = question.getSession().getId(); + + QuestionResDTO.QuestionUpdatedEvent event = new QuestionResDTO.QuestionUpdatedEvent( + "QUESTION_UPDATED", + sessionId, + question.getId(), + question.getContent(), + question.getIsResolved(), + question.getLikeCount(), + isDeleted, + question.getUpdatedAt() + ); + + publishAfterCommit(() -> questionEventService.publishQuestionUpdated(sessionId, event)); + } + private void publishUnderstandingCheckCreatedEventAfterCommit( Long sessionId, UnderstandingCheck check, int attendanceCount ) { @@ -793,4 +869,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 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" /> - +