From 7e6bc95a2eb995492256bc8b0d197dd60b9a5832 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 20:45:12 +0900 Subject: [PATCH 01/19] =?UTF-8?q?[Fix]=20=EC=B6=9C=EC=84=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B3=B4=EC=A6=9D=EA=B8=88=20ID=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/repository/AttendanceCodeRepository.java | 4 +--- .../attendance/repository/AttendanceRepository.java | 9 +-------- .../domain/attendance/service/AttendanceService.java | 3 +-- .../domain/deposit/repository/DepositRepository.java | 2 +- .../project/domain/user/service/AdminUserService.java | 6 +++--- 5 files changed, 7 insertions(+), 17 deletions(-) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java index b407419..0c7fef7 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java @@ -5,14 +5,13 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; import java.time.LocalDate; import java.util.List; import java.util.Optional; -public interface AttendanceCodeRepository extends JpaRepository { +public interface AttendanceCodeRepository extends JpaRepository { // [추가] 모든 활성화된 코드를 한 번에 만료 처리 (벌크 연산) @Modifying @@ -34,4 +33,3 @@ public interface AttendanceCodeRepository extends JpaRepository findByAttendanceDate(LocalDate attendanceDate); } - diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java index 86aeb01..ccd4e54 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java @@ -1,27 +1,20 @@ package com.example.Piroin.project.domain.attendance.repository; -import com.example.Piroin.project.domain.attendance.entity.AttendanceCode; -import com.example.Piroin.project.domain.curriculum.entity.StudySession; import com.example.Piroin.project.domain.user.entity.User; import com.example.Piroin.project.domain.attendance.entity.Attendance; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; import java.time.LocalDate; import java.util.List; import java.util.Optional; -public interface AttendanceRepository extends JpaRepository { +public interface AttendanceRepository extends JpaRepository { Optional findById(Integer id); - // 연관관계 필드명이 attendanceCode 라면 내부 ID인 Id를 조합하여 명명 - Optional findByUserIdAndAttendanceCodeId(Long userId, Long attendanceCodeId); - - int countByUserAndStatusFalse(User user); // 1. 특정 출석 코드 ID에 해당하는 결석 데이터 조회 diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java index 2395098..8d46c95 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java @@ -165,7 +165,7 @@ public AttendanceMarkResponse markAttendance(Long userId, String inputCode) { // 혹은 조회된 code의 날짜/차수 정보를 기반으로 기존 출석 기록을 찾아야 합니다. // (여기서는 이전 답변 시나리오 1인 'attendanceCodeId'로 매핑했다고 가정했을 때의 예시입니다.) Attendance attendance = attendanceRepository - .findByUserIdAndAttendanceCodeId(userId, Long.valueOf(code.getId())) + .findByUserIdAndAttendanceCodeId(userId, code.getId()) .orElse(null); // 해당 사용자와 출석 코드에 대한 출석 기록이 존재하지 않는 경우 @@ -323,4 +323,3 @@ public List findByUserId(Integer userId) { } - diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java index ed5f1d5..3138079 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.Optional; -public interface DepositRepository extends JpaRepository { +public interface DepositRepository extends JpaRepository { Optional findByUser(User user); Optional findByUserId(Long userId); diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java b/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java index 28ed7a3..dfd4873 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java @@ -101,8 +101,8 @@ public UpdateStudentStatusResponse updateStudentWeekStatus( } if (request.getAttendances() != null) { - List attendanceIds = request.getAttendances().stream() - .map(dto -> dto.getAttendanceId().longValue()) + List attendanceIds = request.getAttendances().stream() + .map(UpdateStudentStatusRequest.AttendanceStatusRequest::getAttendanceId) .toList(); Map attendanceMap = attendanceRepository.findAllById(attendanceIds).stream() @@ -176,4 +176,4 @@ private int calculateAssignmentPenalty(AssignmentStatus status) { // 6. 출석에 대한 보증금 계산 로직 // (AttendanceService에 있음!!) -} \ No newline at end of file +} From 0aff7b19a4cb751d159975a90d1ae525d97f4747 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 20:50:49 +0900 Subject: [PATCH 02/19] =?UTF-8?q?[Fix]=20=EC=A7=88=EB=AC=B8=20=EC=9D=B5?= =?UTF-8?q?=EB=AA=85=20=EB=B2=88=ED=98=B8=20=EC=9C=A0=EB=8B=88=ED=81=AC=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/entity/QuestionAnonymousIdentity.java | 7 +------ .../V8__drop_question_anonymous_no_unique_constraint.sql | 4 ++++ 2 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V8__drop_question_anonymous_no_unique_constraint.sql diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java index 94a617e..c6ea157 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java @@ -11,12 +11,8 @@ name = "question_anonymous_identity", uniqueConstraints = { @UniqueConstraint( - name = "uq_question_anon_question_user", + name = "uq_question_anonymous_identity_question_user", columnNames = {"question_id", "user_id"} - ), - @UniqueConstraint( - name = "uq_question_anon_question_no", - columnNames = {"question_id", "anonymous_no"} ) } ) @@ -44,4 +40,3 @@ public class QuestionAnonymousIdentity { @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; } - diff --git a/backend/src/main/resources/db/migration/V8__drop_question_anonymous_no_unique_constraint.sql b/backend/src/main/resources/db/migration/V8__drop_question_anonymous_no_unique_constraint.sql new file mode 100644 index 0000000..0b8117e --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__drop_question_anonymous_no_unique_constraint.sql @@ -0,0 +1,4 @@ +-- Anonymous numbers are scoped by role, so member #1 and admin #1 can coexist in the same question. +-- Drop the legacy question_id + anonymous_no constraint if it exists. +ALTER TABLE question_anonymous_identity + DROP CONSTRAINT IF EXISTS uq_question_anon_question_no; From 5179e64300bea22cca1c7f62c4e8e604dbcaee3c Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 21:15:48 +0900 Subject: [PATCH 03/19] =?UTF-8?q?[Fix]=20=EC=A7=88=EB=AC=B8=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20N+1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../QuestionAnonymousIdentityRepository.java | 17 ++- .../repository/QuestionCommentRepository.java | 22 ++-- .../repository/QuestionRepository.java | 16 ++- .../question/service/QuestionService.java | 101 ++++++++++++++---- 4 files changed, 123 insertions(+), 33 deletions(-) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java index 53448d9..458dc03 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java @@ -8,7 +8,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; +import java.util.Set; public interface QuestionAnonymousIdentityRepository extends JpaRepository { @@ -16,6 +18,19 @@ public interface QuestionAnonymousIdentityRepository extends JpaRepository findByQuestionAndUser(Question question, User user); + // 질문 상세 조회용: 댓글 작성자들의 익명 번호를 한 번에 조회 + @Query(""" + SELECT identity + FROM QuestionAnonymousIdentity identity + JOIN FETCH identity.user + WHERE identity.question = :question + AND identity.user.id IN :userIds + """) + List findByQuestionAndUserIds( + @Param("question") Question question, + @Param("userIds") Set userIds + ); + // 해당 질문에서 특정 역할(MEMBER/ADMIN)의 익명 번호 수 조회 // 용도: 새 익명 번호 발급 시 역할별로 따로 카운트 // MEMBER → 익명1, 익명2... / ADMIN → 운영진1, 운영진2... @@ -26,4 +41,4 @@ int findMaxAnonymousNoByQuestionAndRole( @Param("question") Question question, @Param("role") Role role ); -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java index 95da5bf..7d3bc11 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java @@ -11,11 +11,19 @@ public interface QuestionCommentRepository extends JpaRepository { /* - 특정 질문의 삭제되지 않은 최상위 댓글 목록(등록순) - parentComment가 null인 것 = 대댓글이 아닌 최상위 댓글 - 용도: 질문 상세 페이지에서 댓글 목록 표시 시 + 질문 상세 조회용 댓글 목록을 한 번에 가져온다. + 댓글 작성자와 부모 댓글을 함께 로딩해 댓글/대댓글 DTO 조립 중 N+1 조회를 피한다. */ - List findByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtAsc(Question question); + @Query(""" + SELECT comment + FROM QuestionComment comment + JOIN FETCH comment.user + LEFT JOIN FETCH comment.parentComment + WHERE comment.question = :question + AND comment.deletedAt IS NULL + ORDER BY comment.createdAt ASC, comment.id ASC + """) + List findByQuestionWithUserAndParentComment(@Param("question") Question question); /* 질문 목록 미리보기용 최상위 댓글 3개를 질문별로 한 번에 조회한다. @@ -64,12 +72,6 @@ WHERE qc.question_id IN (:questionIds) """) List countByQuestionIds(@Param("questionIds") List questionIds); - /* - 특정 댓글의 대댓글 목록(등록순) - 용도: 댓글 아래 대댓글을 가져올 때 - */ - List findByParentCommentAndDeletedAtIsNullOrderByCreatedAtAsc(QuestionComment parentComment); - interface PreviewCommentRow { Long getQuestionId(); diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java index 9f5a941..4b6db37 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java @@ -3,6 +3,8 @@ import com.example.Piroin.project.domain.curriculum.entity.StudySession; import com.example.Piroin.project.domain.question.entity.Question; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -19,4 +21,16 @@ public interface QuestionRepository extends JpaRepository { 용도: 질문 상세 조회, 수정, 삭제, 좋아요 처리 시 */ Optional findByIdAndDeletedAtIsNull(Long id); -} \ No newline at end of file + + /* + 질문 상세 조회용: 질문 작성자를 함께 가져와 상세 DTO 조립 중 추가 조회를 피한다. + */ + @Query(""" + SELECT question + FROM Question question + JOIN FETCH question.user + WHERE question.id = :id + AND question.deletedAt IS NULL + """) + Optional findDetailByIdAndDeletedAtIsNull(@Param("id") Long id); +} 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 008597d..1ad6930 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 @@ -75,7 +75,7 @@ public SseEmitter subscribeQuestionEvents(Long sessionId) { @Transactional(readOnly = true) public QuestionResDTO.QuestionDetailResponse getQuestionDetail(Long questionId, Long userId) { User loginUser = findLoginUser(userId); - Question question = findQuestion(questionId); + Question question = findQuestionDetail(questionId); return toDetailResponse(question, loginUser); } @@ -84,11 +84,11 @@ private QuestionResDTO.QuestionDetailResponse toDetailResponse(Question question boolean isMine = question.getUser().getId().equals(loginUser.getId()); boolean isPopular = !question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD; - List topComments = - questionCommentRepository.findByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtAsc(question); + List comments = questionCommentRepository.findByQuestionWithUserAndParentComment(question); + DetailCommentContext commentContext = getDetailCommentContext(question, comments); - List commentResponses = topComments.stream() - .map(comment -> toCommentResponse(question, comment, loginUser)) + List commentResponses = commentContext.topComments().stream() + .map(comment -> toTopLevelCommentResponse(question, comment, loginUser, commentContext)) .toList(); return new QuestionResDTO.QuestionDetailResponse( @@ -99,25 +99,71 @@ private QuestionResDTO.QuestionDetailResponse toDetailResponse(Question question ); } - private QuestionResDTO.CommentResponse toCommentResponse(Question question, QuestionComment comment, User loginUser) { - List replies = - questionCommentRepository.findByParentCommentAndDeletedAtIsNullOrderByCreatedAtAsc(comment); + private DetailCommentContext getDetailCommentContext(Question question, List comments) { + List topComments = new ArrayList<>(); + Map> repliesByParentId = new HashMap<>(); + Set commenterIds = new HashSet<>(); + + for (QuestionComment comment : comments) { + commenterIds.add(comment.getUser().getId()); + + QuestionComment parentComment = comment.getParentComment(); + if (parentComment == null) { + topComments.add(comment); + continue; + } + repliesByParentId.computeIfAbsent(parentComment.getId(), key -> new ArrayList<>()) + .add(comment); + } + + Long questionAuthorId = question.getUser().getId(); + Set anonymousUserIds = commenterIds.stream() + .filter(commenterId -> !commenterId.equals(questionAuthorId)) + .collect(Collectors.toSet()); + + Map anonymousNumbersByUserId = new HashMap<>(); + if (!anonymousUserIds.isEmpty()) { + anonymousIdentityRepository.findByQuestionAndUserIds(question, anonymousUserIds) + .forEach(identity -> anonymousNumbersByUserId.put( + identity.getUser().getId(), identity.getAnonymousNo() + )); + } + + return new DetailCommentContext(topComments, repliesByParentId, anonymousNumbersByUserId); + } - List replyResponses = replies.stream() - .map(reply -> new QuestionResDTO.CommentResponse( - reply.getId(), getDisplayName(question, reply.getUser()), - reply.getContent(), reply.getImageUrls(), isCommentMine(reply, loginUser), - reply.getCreatedAt(), List.of() - )) + private QuestionResDTO.CommentResponse toTopLevelCommentResponse( + Question question, + QuestionComment comment, + User loginUser, + DetailCommentContext commentContext + ) { + List replyResponses = commentContext.repliesByParentId() + .getOrDefault(comment.getId(), List.of()) + .stream() + .map(reply -> toReplyCommentResponse(question, reply, loginUser, commentContext)) .toList(); return new QuestionResDTO.CommentResponse( - comment.getId(), getDisplayName(question, comment.getUser()), + comment.getId(), getDisplayName(question, comment.getUser(), commentContext.anonymousNumbersByUserId()), comment.getContent(), comment.getImageUrls(), isCommentMine(comment, loginUser), comment.getCreatedAt(), replyResponses ); } + private QuestionResDTO.CommentResponse toReplyCommentResponse( + Question question, + QuestionComment reply, + User loginUser, + DetailCommentContext commentContext + ) { + return new QuestionResDTO.CommentResponse( + reply.getId(), getDisplayName(question, reply.getUser(), commentContext.anonymousNumbersByUserId()), + reply.getContent(), reply.getImageUrls(), isCommentMine(reply, loginUser), + reply.getCreatedAt(), List.of() + ); + } + private boolean isCommentMine(QuestionComment comment, User loginUser) { return comment.getUser().getId().equals(loginUser.getId()); } @@ -234,15 +280,16 @@ private String buildDisplayName(Role role, int anonymousNo) { return role == Role.ADMIN ? "운영진" + anonymousNo : "익명" + anonymousNo; } - // getDisplayName: 상세 조회 시 기존 익명 번호 읽기 (번호 부여 없음) - private String getDisplayName(Question question, User commenter) { + private String getDisplayName(Question question, User commenter, Map anonymousNumbersByUserId) { if (commenter.getId().equals(question.getUser().getId())) { return "작성자"; } - return anonymousIdentityRepository - .findByQuestionAndUser(question, commenter) - .map(identity -> buildDisplayName(commenter.getRole(), identity.getAnonymousNo())) - .orElse(commenter.getRole() == Role.ADMIN ? "운영진" : "익명"); + + Integer anonymousNo = anonymousNumbersByUserId.get(commenter.getId()); + if (anonymousNo == null) { + return commenter.getRole() == Role.ADMIN ? "운영진" : "익명"; + } + return buildDisplayName(commenter.getRole(), anonymousNo); } // 질문 등록 @@ -476,6 +523,11 @@ private Question findQuestion(Long questionId) { .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); } + private Question findQuestionDetail(Long questionId) { + return questionRepository.findDetailByIdAndDeletedAtIsNull(questionId) + .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); + } + private StudySession findSession(Long sessionId) { return curriculumRepository.findById(sessionId) .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다.")); @@ -871,6 +923,13 @@ private record QuestionSummaryContext( ) { } + private record DetailCommentContext( + List topComments, + Map> repliesByParentId, + Map anonymousNumbersByUserId + ) { + } + // 질문은 내용 또는 이미지 중 하나는 반드시 있어야 함 private void validateQuestionContent(String content, List imageUrls) { boolean hasContent = content != null && !content.isBlank(); From ef728376d52f3b8ba4f4d7c634057b94e8be6413 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 21:42:43 +0900 Subject: [PATCH 04/19] =?UTF-8?q?[Fix]=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/repository/QuestionRepository.java | 14 ++++++++++++++ .../domain/question/service/QuestionService.java | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java index 4b6db37..b664c31 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java @@ -2,7 +2,9 @@ import com.example.Piroin.project.domain.curriculum.entity.StudySession; import com.example.Piroin.project.domain.question.entity.Question; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -33,4 +35,16 @@ public interface QuestionRepository extends JpaRepository { AND question.deletedAt IS NULL """) Optional findDetailByIdAndDeletedAtIsNull(@Param("id") Long id); + + /* + 좋아요 카운트 갱신용: 같은 질문에 대한 동시 토글 요청을 직렬화해 likeCount lost update를 방지한다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT question + FROM Question question + WHERE question.id = :id + AND question.deletedAt IS NULL + """) + Optional findByIdAndDeletedAtIsNullForUpdate(@Param("id") Long id); } 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 1ad6930..0edd8cc 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 @@ -325,7 +325,7 @@ public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.Cr @Transactional public QuestionResDTO.LikeRes toggleLike(Long questionId, Long userId) { User loginUser = findLoginUser(userId); - Question question = findQuestion(questionId); + Question question = findQuestionForLikeUpdate(questionId); // 이미 좋아요를 눌렀는지 확인 QuestionResDTO.LikeRes result = questionLikeRepository.findByQuestionAndUser(question, loginUser) @@ -528,6 +528,11 @@ private Question findQuestionDetail(Long questionId) { .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); } + private Question findQuestionForLikeUpdate(Long questionId) { + return questionRepository.findByIdAndDeletedAtIsNullForUpdate(questionId) + .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); + } + private StudySession findSession(Long sessionId) { return curriculumRepository.findById(sessionId) .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다.")); From ebd8030985e97c6c55f0bf3c9f0e68a03f3eda3e Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 22:14:01 +0900 Subject: [PATCH 05/19] =?UTF-8?q?[Fix]=20=EC=9D=B5=EB=AA=85=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=97=AD=ED=95=A0=EB=B3=84=20=EC=9C=A0=EB=8B=88?= =?UTF-8?q?=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/QuestionAnonymousIdentity.java | 9 ++++ .../QuestionAnonymousIdentityRepository.java | 12 +++--- .../repository/QuestionCommentRepository.java | 2 +- .../question/service/QuestionService.java | 42 ++++++++++++------- ...dd_role_to_question_anonymous_identity.sql | 33 +++++++++++++++ 5 files changed, 76 insertions(+), 22 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V9__add_role_to_question_anonymous_identity.sql diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java index c6ea157..3759ddf 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java @@ -1,6 +1,7 @@ package com.example.Piroin.project.domain.question.entity; import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.enums.Role; import jakarta.persistence.*; import lombok.*; @@ -13,6 +14,10 @@ @UniqueConstraint( name = "uq_question_anonymous_identity_question_user", columnNames = {"question_id", "user_id"} + ), + @UniqueConstraint( + name = "uq_question_anonymous_identity_question_role_no", + columnNames = {"question_id", "role", "anonymous_no"} ) } ) @@ -37,6 +42,10 @@ public class QuestionAnonymousIdentity { @Column(name = "anonymous_no", nullable = false) private Integer anonymousNo; + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private Role role; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java index 458dc03..a2d1e22 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java @@ -31,12 +31,12 @@ List findByQuestionAndUserIds( @Param("userIds") Set userIds ); - // 해당 질문에서 특정 역할(MEMBER/ADMIN)의 익명 번호 수 조회 - // 용도: 새 익명 번호 발급 시 역할별로 따로 카운트 - // MEMBER → 익명1, 익명2... / ADMIN → 운영진1, 운영진2... - int countByQuestionAndUser_Role(Question question, Role role); - - @Query("SELECT COALESCE(MAX(a.anonymousNo), 0) FROM QuestionAnonymousIdentity a " + "WHERE a.question = :question AND a.user.role = :role") + @Query(""" + SELECT COALESCE(MAX(identity.anonymousNo), 0) + FROM QuestionAnonymousIdentity identity + WHERE identity.question = :question + AND identity.role = :role + """) int findMaxAnonymousNoByQuestionAndRole( @Param("question") Question question, @Param("role") Role role diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java index 7d3bc11..521b5bb 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java @@ -33,7 +33,7 @@ public interface QuestionCommentRepository extends JpaRepository !commenterId.equals(questionAuthorId)) .collect(Collectors.toSet()); - Map anonymousNumbersByUserId = new HashMap<>(); + Map anonymousIdentitiesByUserId = new HashMap<>(); if (!anonymousUserIds.isEmpty()) { anonymousIdentityRepository.findByQuestionAndUserIds(question, anonymousUserIds) - .forEach(identity -> anonymousNumbersByUserId.put( - identity.getUser().getId(), identity.getAnonymousNo() + .forEach(identity -> anonymousIdentitiesByUserId.put( + identity.getUser().getId(), + new AnonymousIdentityDisplay(identity.getRole(), identity.getAnonymousNo()) )); } - return new DetailCommentContext(topComments, repliesByParentId, anonymousNumbersByUserId); + return new DetailCommentContext(topComments, repliesByParentId, anonymousIdentitiesByUserId); } private QuestionResDTO.CommentResponse toTopLevelCommentResponse( @@ -145,7 +146,7 @@ private QuestionResDTO.CommentResponse toTopLevelCommentResponse( .toList(); return new QuestionResDTO.CommentResponse( - comment.getId(), getDisplayName(question, comment.getUser(), commentContext.anonymousNumbersByUserId()), + comment.getId(), getDisplayName(question, comment.getUser(), commentContext.anonymousIdentitiesByUserId()), comment.getContent(), comment.getImageUrls(), isCommentMine(comment, loginUser), comment.getCreatedAt(), replyResponses ); @@ -158,7 +159,7 @@ private QuestionResDTO.CommentResponse toReplyCommentResponse( DetailCommentContext commentContext ) { return new QuestionResDTO.CommentResponse( - reply.getId(), getDisplayName(question, reply.getUser(), commentContext.anonymousNumbersByUserId()), + reply.getId(), getDisplayName(question, reply.getUser(), commentContext.anonymousIdentitiesByUserId()), reply.getContent(), reply.getImageUrls(), isCommentMine(reply, loginUser), reply.getCreatedAt(), List.of() ); @@ -177,7 +178,7 @@ public QuestionResDTO.CommentCreateRes createComment( Long userId ) { User loginUser = findLoginUser(userId); - Question question = findQuestion(questionId); + Question question = findQuestionForUpdate(questionId); // 1. 대댓글 여부 확인: parentCommentId가 있으면 부모 댓글 조회 QuestionComment parentComment = resolveParentComment(request.getParentCommentId(), question); @@ -258,7 +259,7 @@ private String assignAnonymousIdentity(Question question, User commenter) { // 이미 이 질문에서 익명 번호가 있는지 확인 return anonymousIdentityRepository .findByQuestionAndUser(question, commenter) - .map(identity -> buildDisplayName(commenter.getRole(), identity.getAnonymousNo())) + .map(identity -> buildDisplayName(identity.getRole(), identity.getAnonymousNo())) .orElseGet(() -> { // 처음 댓글 다는 유저 → 역할별 카운트 기반으로 새 번호 부여 int nextNo = anonymousIdentityRepository @@ -268,6 +269,7 @@ private String assignAnonymousIdentity(Question question, User commenter) { .question(question) .user(commenter) .anonymousNo(nextNo) + .role(commenter.getRole()) .createdAt(LocalDateTime.now()) .build()); @@ -280,16 +282,20 @@ private String buildDisplayName(Role role, int anonymousNo) { return role == Role.ADMIN ? "운영진" + anonymousNo : "익명" + anonymousNo; } - private String getDisplayName(Question question, User commenter, Map anonymousNumbersByUserId) { + private String getDisplayName( + Question question, + User commenter, + Map anonymousIdentitiesByUserId + ) { if (commenter.getId().equals(question.getUser().getId())) { return "작성자"; } - Integer anonymousNo = anonymousNumbersByUserId.get(commenter.getId()); - if (anonymousNo == null) { + AnonymousIdentityDisplay identity = anonymousIdentitiesByUserId.get(commenter.getId()); + if (identity == null) { return commenter.getRole() == Role.ADMIN ? "운영진" : "익명"; } - return buildDisplayName(commenter.getRole(), anonymousNo); + return buildDisplayName(identity.role(), identity.anonymousNo()); } // 질문 등록 @@ -325,7 +331,7 @@ public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.Cr @Transactional public QuestionResDTO.LikeRes toggleLike(Long questionId, Long userId) { User loginUser = findLoginUser(userId); - Question question = findQuestionForLikeUpdate(questionId); + Question question = findQuestionForUpdate(questionId); // 이미 좋아요를 눌렀는지 확인 QuestionResDTO.LikeRes result = questionLikeRepository.findByQuestionAndUser(question, loginUser) @@ -528,7 +534,7 @@ private Question findQuestionDetail(Long questionId) { .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); } - private Question findQuestionForLikeUpdate(Long questionId) { + private Question findQuestionForUpdate(Long questionId) { return questionRepository.findByIdAndDeletedAtIsNullForUpdate(questionId) .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); } @@ -931,7 +937,13 @@ private record QuestionSummaryContext( private record DetailCommentContext( List topComments, Map> repliesByParentId, - Map anonymousNumbersByUserId + Map anonymousIdentitiesByUserId + ) { + } + + private record AnonymousIdentityDisplay( + Role role, + Integer anonymousNo ) { } diff --git a/backend/src/main/resources/db/migration/V9__add_role_to_question_anonymous_identity.sql b/backend/src/main/resources/db/migration/V9__add_role_to_question_anonymous_identity.sql new file mode 100644 index 0000000..e79e6fd --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_role_to_question_anonymous_identity.sql @@ -0,0 +1,33 @@ +ALTER TABLE question_anonymous_identity + ADD COLUMN role VARCHAR(20); + +UPDATE question_anonymous_identity identity +SET role = users.role +FROM users +WHERE identity.user_id = users.id + AND identity.role IS NULL; + +-- Normalize any duplicate numbers that may have been created while the role-scoped constraint was absent. +WITH ranked_identity AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY question_id, role + ORDER BY anonymous_no ASC, created_at ASC, id ASC + ) AS next_anonymous_no + FROM question_anonymous_identity +) +UPDATE question_anonymous_identity identity +SET anonymous_no = ranked_identity.next_anonymous_no +FROM ranked_identity +WHERE identity.id = ranked_identity.id; + +ALTER TABLE question_anonymous_identity + ALTER COLUMN role SET NOT NULL; + +ALTER TABLE question_anonymous_identity + ADD CONSTRAINT chk_question_anonymous_identity_role + CHECK (role IN ('ADMIN', 'MEMBER')); + +ALTER TABLE question_anonymous_identity + ADD CONSTRAINT uq_question_anonymous_identity_question_role_no + UNIQUE (question_id, role, anonymous_no); From 63e8aecc22f41470a6aa54056a423aaa7a66a1a2 Mon Sep 17 00:00:00 2001 From: lilyyang0077 Date: Tue, 16 Jun 2026 22:27:55 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=EB=8F=99=EC=A0=84=20=EC=A6=89?= =?UTF-8?q?=EC=8B=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/pirocheck/attendance/Attendance.js | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/frontend/src/pages/pirocheck/attendance/Attendance.js b/frontend/src/pages/pirocheck/attendance/Attendance.js index 77968ae..5119587 100644 --- a/frontend/src/pages/pirocheck/attendance/Attendance.js +++ b/frontend/src/pages/pirocheck/attendance/Attendance.js @@ -102,28 +102,32 @@ function MemberView() { const [message, setMessage] = useState(''); const [todaySlots, setTodaySlots] = useState([]); const [history, setHistory] = useState([]); + const dayOrder = ['TUESDAY', 'THURSDAY', 'SATURDAY']; - useEffect(() => { - const today = new Date().toISOString().split('T')[0]; - - authFetch(`/api/attendance/user/date?date=${today}`) - .then(r => r.json()) - .then(d => setTodaySlots(d.data || [])) - .catch(() => setTodaySlots([])); + const fetchTodaySlots = async () => { + const today = new Date().toISOString().split('T')[0]; - const dayOrder = ['TUESDAY', 'THURSDAY', 'SATURDAY']; + try { + const res = await authFetch(`/api/attendance/user/date?date=${today}`); + const data = await res.json(); + setTodaySlots(data.data || []); + } catch (e) { + setTodaySlots([]); + } + }; - const defaultHistory = [1, 2, 3, 4, 5].map(week => ({ - week, - days: dayOrder.map(day => ({ - day, - slots: [] - })) - })); - - authFetch('/api/attendance/user') - .then(r => r.json()) - .then(data => { + const fetchHistory = async () => { + const defaultHistory = [1, 2, 3, 4, 5].map(week => ({ + week, + days: dayOrder.map(day => ({ + day, + slots: [] + })) + })); + + try { + const res = await authFetch('/api/attendance/user'); + const data = await res.json(); const apiData = data.data || []; const merged = defaultHistory.map(def => { @@ -149,30 +153,37 @@ function MemberView() { }); setHistory(merged); - }) - .catch(() => setHistory(defaultHistory)); -}, []); + } catch (e) { + setHistory(defaultHistory); + } + }; + + useEffect(() => { + fetchTodaySlots(); + fetchHistory(); + }, []); const handleSubmit = async () => { if (!inputCode.trim()) return; + const res = await authFetch('/api/attendance/mark', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: inputCode }), }); + const data = await res.json(); const result = data.data; if (result.statusCode === 'SUCCESS') { setMessage('출석 성공!'); - const today = new Date().toISOString().split('T')[0]; - authFetch(`/api/attendance/user/date?date=${today}`) - .then(r => r.json()) - .then(d => setTodaySlots(d.data || [])); + + await fetchTodaySlots(); + await fetchHistory(); } else if (result.statusCode === 'INVALID_CODE') { setMessage('출석 코드를 확인해주세요.'); } else { - setMessage(result.message); + setMessage(result.message); } setInputCode(''); From 5bd58036bf2dd6cace089a5a5b3301cefabc6024 Mon Sep 17 00:00:00 2001 From: kdhye1119 Date: Thu, 18 Jun 2026 12:32:29 +0900 Subject: [PATCH 07/19] =?UTF-8?q?[feat]=20=EC=A7=88=EB=AC=B8=20=EB=94=94?= =?UTF-8?q?=ED=85=8C=EC=9D=BC=20=ED=83=AD=EC=97=90=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9C=BC=EB=A1=9C=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/curriculum/CurriculumPage.js | 60 +++++++++---------- frontend/src/pages/qna/QnADetailPage.js | 20 ++++++- .../src/pages/qna/QnADetailPage.module.css | 32 ++++++++++ frontend/src/pages/qna/QnAMainPage.js | 2 +- 4 files changed, 80 insertions(+), 34 deletions(-) diff --git a/frontend/src/pages/curriculum/CurriculumPage.js b/frontend/src/pages/curriculum/CurriculumPage.js index e65f1ce..6375a2f 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.js +++ b/frontend/src/pages/curriculum/CurriculumPage.js @@ -67,7 +67,7 @@ function MemberSessionCard({ day }) { toggle -
+
{isOpen && (
@@ -104,7 +104,7 @@ function AdminSessionCard({ day, onEdit, onDelete }) {
toggle -
+
{isOpen && (
@@ -161,7 +161,7 @@ function SessionForm({ day, week, onClose, onSave }) { const date = new Date(year, month - 1, day); const map = { 2: '화요일', 4: '목요일', 6: '토요일' }; return map[date.getDay()] || ''; - }; + }; const handleSave = async () => { const body = { @@ -219,27 +219,27 @@ function SessionForm({ day, week, onClose, onSave }) { return (
-
- - -
- -
-
- - -
-
- - setForm({ ...form, sessionDate: e.target.value })} /> -
-
+
+ + +
+ +
+
+ + +
+
+ + setForm({ ...form, sessionDate: e.target.value })} /> +
+
{/* 오전 세션 */}
@@ -290,8 +290,8 @@ function SessionForm({ day, week, onClose, onSave }) { {/* 과제 */}
과제 -
setForm({ ...form, assignmentName: e.target.value })} />
-
setForm({ ...form, assignmentUrl: e.target.value })} />
+
setForm({ ...form, assignmentName: e.target.value })} />
+
setForm({ ...form, assignmentUrl: e.target.value })} />
@@ -313,7 +313,7 @@ function CurriculumPage() { const res = await authFetch('/api/curriculums'); const data = await res.json(); setDays(Array.isArray(data) ? data : []); - } catch (e) {} + } catch (e) { } }; useEffect(() => { fetchDays(); }, []); @@ -347,7 +347,7 @@ function CurriculumPage() { 세션 생성
- )} + )} {Object.entries(grouped).map(([week, weekDays]) => (
@@ -370,8 +370,8 @@ function CurriculumPage() { ))} {showForm && ( - - { setShowForm(false); setEditDay(null); setCreateWeek(null); }} diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index d5c8d23..7e27f72 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -2,7 +2,7 @@ import '../../assets/styles/global.css'; import { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import styles from './QnADetailPage.module.css'; -import { FiMoreVertical, FiCornerDownRight } from 'react-icons/fi'; +import { FiMoreVertical, FiCornerDownRight, FiChevronLeft } from 'react-icons/fi'; import { CommentImoji, MeCuriousToo, @@ -25,7 +25,7 @@ const formatTime = (dateStr) => { }; function QnADetailPage() { - const { questionId } = useParams(); + const { sessionId, questionId } = useParams(); const navigate = useNavigate(); const isStaff = localStorage.getItem('role') === 'ADMIN'; @@ -53,7 +53,7 @@ function QnADetailPage() { // ── 질문 불러오기 ──────────────────────────────── useEffect(() => { document.title = "Q&A | PIROIN"; - + const fetchQuestion = async () => { try { setLoading(true); @@ -112,6 +112,11 @@ function QnADetailPage() { return () => document.removeEventListener('click', handleClickOutside); }, [showMenu, commentMenuId]); + // ── 목록으로 가기 ──────────────────────────────── + const handleBackToList = () => { + navigate(`/sessions/${sessionId}/questions/`); + }; + // ── 좋아요 토글 ────────────────────────────────── const toggleLike = async () => { try { @@ -302,6 +307,15 @@ function QnADetailPage() { return (
+ {/* ── 목록으로 가기 ── */} + + {/* ── 작성자 행 ── */}
diff --git a/frontend/src/pages/qna/QnADetailPage.module.css b/frontend/src/pages/qna/QnADetailPage.module.css index 0e4fd83..c9e2297 100644 --- a/frontend/src/pages/qna/QnADetailPage.module.css +++ b/frontend/src/pages/qna/QnADetailPage.module.css @@ -12,6 +12,32 @@ box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 0.25); } +/* ── 목록으로 가기 ── */ +.backToListBtn { + display: flex; + align-items: center; + gap: 4px; + background: none; + border: none; + cursor: pointer; + font-family: var(--font-main); + font-size: 14px; + font-weight: 500; + color: var(--gray600); + padding: 0; + margin: 0 0 18px; + align-self: flex-start; + transition: color 0.15s; +} + +.backToListBtn:hover { + color: var(--black); +} + +.backToListBtn svg { + flex-shrink: 0; +} + /* ── 작성자 행 ── */ .authorRow { display: flex; @@ -561,6 +587,12 @@ border-radius: 16px 16px 0 0; } + /* ── 목록으로 가기 ── */ + .backToListBtn { + font-size: 13px; + margin-bottom: 14px; + } + /* ── 질문 본문 ── */ .qIcon { font-size: 28px; diff --git a/frontend/src/pages/qna/QnAMainPage.js b/frontend/src/pages/qna/QnAMainPage.js index 15c039e..892a3ee 100644 --- a/frontend/src/pages/qna/QnAMainPage.js +++ b/frontend/src/pages/qna/QnAMainPage.js @@ -18,7 +18,7 @@ function QNAMainPage() { // ── 세션 목록 불러오기 ────────────────────────── useEffect(() => { document.title = "Q&A | PIROIN"; - + const fetchSessions = async () => { try { setLoading(true); From 0e5db132a054890cdf7d86fac2a19762784bf94a Mon Sep 17 00:00:00 2001 From: kdhye1119 Date: Thu, 18 Jun 2026 12:35:23 +0900 Subject: [PATCH 08/19] =?UTF-8?q?[feat]=20QnA=20->=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnAMainPage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/qna/QnAMainPage.js b/frontend/src/pages/qna/QnAMainPage.js index 892a3ee..f799420 100644 --- a/frontend/src/pages/qna/QnAMainPage.js +++ b/frontend/src/pages/qna/QnAMainPage.js @@ -50,7 +50,7 @@ function QNAMainPage() { {activeSessions.length > 0 && ( <>
-

Q&A

+

현재 세션

{activeSessions.map(session => (
Date: Thu, 18 Jun 2026 12:47:23 +0900 Subject: [PATCH 09/19] =?UTF-8?q?[feat]=20=EC=9D=B4=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=EC=97=90=EB=8A=94=20=EC=9D=B4=ED=95=B4=EB=8F=84=20=EB=B0=94=20?= =?UTF-8?q?=EC=95=88=EB=9C=A8=EA=B2=8C=20=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnAListPage.js | 68 ++++++++++++++------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 635d97c..ae65ef6 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -379,40 +379,42 @@ function QnAListPage() {

- {/* ── 이해도 바 ── */} -
- - - {understanding?.current?.content ?? '이해도 없음'} - - ({understanding?.current?.respondedCount ?? 0}/ - {understanding?.current?.attendanceCount ?? 0}) + {/* ── 이해도 바 (이해도 체크가 없으면 숨김) ── */} + {understanding?.current?.checkId != null && ( +
+ + + {understanding.current.content} + + ({understanding.current.respondedCount ?? 0}/ + {understanding.current.attendanceCount ?? 0}) + - - - - -
+ + + +
+ )} {/* ── 질문 목록 ── */}
From 0784a06e636793eef01a6303cd2d5a9fc48166c5 Mon Sep 17 00:00:00 2001 From: kdhye1119 Date: Thu, 18 Jun 2026 13:26:19 +0900 Subject: [PATCH 10/19] =?UTF-8?q?[bug]=20=EC=82=AC=EC=A7=84=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=B0=8F=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89?= =?UTF-8?q?=ED=8A=B8=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnADetailPage.js | 125 +++++++++++- .../src/pages/qna/QnADetailPage.module.css | 192 +++++++++++++++++- frontend/src/pages/qna/QnAListPage.js | 20 +- frontend/src/utils/Api.js | 32 ++- 4 files changed, 358 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index 52d2e1e..bb93229 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -2,7 +2,7 @@ import '../../assets/styles/global.css'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import styles from './QnADetailPage.module.css'; -import { FiMoreVertical, FiCornerDownRight, FiChevronLeft } from 'react-icons/fi'; +import { FiMoreVertical, FiCornerDownRight, FiChevronLeft, FiChevronRight, FiX } from 'react-icons/fi'; import { CommentImoji, MeCuriousToo, @@ -97,6 +97,60 @@ function QnADetailPage() { const [editingCommentId, setEditingCommentId] = useState(null); const [editCommentText, setEditCommentText] = useState(''); + // ── 이미지 확대보기(라이트박스) 상태 ───────────── + // images: 같은 묶음(질문 또는 한 댓글)의 이미지 url 배열, index: 현재 보고 있는 인덱스 + const [lightbox, setLightbox] = useState(null); + + const openLightbox = (images, index) => setLightbox({ images, index }); + const closeLightbox = () => setLightbox(null); + + const showPrevImage = useCallback(() => { + setLightbox(prev => { + if (!prev) return prev; + const nextIndex = (prev.index - 1 + prev.images.length) % prev.images.length; + return { ...prev, index: nextIndex }; + }); + }, []); + + const showNextImage = useCallback(() => { + setLightbox(prev => { + if (!prev) return prev; + const nextIndex = (prev.index + 1) % prev.images.length; + return { ...prev, index: nextIndex }; + }); + }, []); + + useEffect(() => { + if (!lightbox) return undefined; + + const handleKeyDown = (e) => { + if (e.key === 'Escape') closeLightbox(); + if (e.key === 'ArrowLeft') showPrevImage(); + if (e.key === 'ArrowRight') showNextImage(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [lightbox, showPrevImage, showNextImage]); + + // 모바일 좌우 스와이프로 이미지 넘기기 + const touchStartXRef = useRef(null); + + const handleLightboxTouchStart = (e) => { + touchStartXRef.current = e.touches[0].clientX; + }; + + const handleLightboxTouchEnd = (e) => { + if (touchStartXRef.current === null) return; + const deltaX = e.changedTouches[0].clientX - touchStartXRef.current; + const SWIPE_THRESHOLD = 50; + if (deltaX > SWIPE_THRESHOLD) { + showPrevImage(); + } else if (deltaX < -SWIPE_THRESHOLD) { + showNextImage(); + } + touchStartXRef.current = null; + }; + const fetchQuestion = useCallback(async ({ showLoading = false } = {}) => { try { @@ -446,7 +500,13 @@ function QnADetailPage() { {comment.imageUrls?.length > 0 && (
{comment.imageUrls.map((url, idx) => ( - {`댓글 + {`댓글 openLightbox(comment.imageUrls, idx)} + /> ))}
)} @@ -544,7 +604,13 @@ function QnADetailPage() { {question.imageUrls?.length > 0 && (
{question.imageUrls.map((url, idx) => ( - {`첨부 + {`첨부 openLightbox(question.imageUrls, idx)} + /> ))}
)} @@ -622,8 +688,59 @@ function QnADetailPage() {
+ + {/* ── 이미지 확대보기 ── */} + {lightbox && ( +
+ + + {lightbox.images.length > 1 && ( + + )} + + {`확대 e.stopPropagation()} + /> + + {lightbox.images.length > 1 && ( + + )} + + {lightbox.images.length > 1 && ( +
+ {lightbox.index + 1} / {lightbox.images.length} +
+ )} +
+ )}
); } -export default QnADetailPage; +export default QnADetailPage; \ No newline at end of file diff --git a/frontend/src/pages/qna/QnADetailPage.module.css b/frontend/src/pages/qna/QnADetailPage.module.css index c1e4091..83616c3 100644 --- a/frontend/src/pages/qna/QnADetailPage.module.css +++ b/frontend/src/pages/qna/QnADetailPage.module.css @@ -244,12 +244,29 @@ } /* ── 질문 첨부 이미지 ── */ +.questionImages { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 18px; +} + .questionImage { width: 100%; border-radius: 12px; object-fit: cover; display: block; - margin-bottom: 18px; + cursor: zoom-in; + transition: opacity 0.15s, transform 0.15s; +} + +.questionImage:hover { + opacity: 0.92; +} + +/* 이미지가 여러 장일 때는 한 줄에 나눠 배치 (간격은 위 gap 속성으로 처리) */ +.questionImages:has(.questionImage:nth-child(2)) .questionImage { + width: calc(50% - 5px); } /* ── 액션 버튼 행 (좋아요 / 댓글달기) ── */ @@ -419,12 +436,30 @@ margin-top: 2px; } +.commentImages { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .commentImage { width: 100%; max-width: 380px; border-radius: 8px; object-fit: cover; display: block; + cursor: zoom-in; + transition: opacity 0.15s; +} + +.commentImage:hover { + opacity: 0.92; +} + +/* 댓글에 이미지가 여러 장일 때는 한 줄에 나눠 배치 */ +.commentImages:has(.commentImage:nth-child(2)) .commentImage { + width: calc(50% - 4px); + max-width: calc(50% - 4px); } .commentDate { @@ -550,6 +585,12 @@ } /* ── 이미지 미리보기 ── */ +.imagePreviewList { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .imagePreviewWrapper { position: relative; display: inline-block; @@ -558,6 +599,14 @@ align-self: flex-start; } +.imagePreviewList .imagePreviewWrapper { + margin: 4px 0 0 0; +} + +.imagePreviewList .imagePreviewWrapper:first-child { + margin-left: 12px; +} + .imagePreview { width: 80px; height: 80px; @@ -583,6 +632,109 @@ z-index: 101; } +/* ── 이미지 확대보기(라이트박스) ── */ +.lightboxOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 40px; + box-sizing: border-box; + cursor: zoom-out; + animation: lightboxFadeIn 0.15s ease-out; +} + +@keyframes lightboxFadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.lightboxImage { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 8px; + cursor: default; + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.4); +} + +.lightboxCloseBtn { + position: fixed; + top: 24px; + right: 24px; + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + border: none; + color: var(--white); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1001; + transition: background 0.15s; +} + +.lightboxCloseBtn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.lightboxPrevBtn, +.lightboxNextBtn { + position: fixed; + top: 50%; + transform: translateY(-50%); + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + border: none; + color: var(--white); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1001; + transition: background 0.15s; +} + +.lightboxPrevBtn:hover, +.lightboxNextBtn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.lightboxPrevBtn { + left: 24px; +} + +.lightboxNextBtn { + right: 24px; +} + +.lightboxCounter { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.15); + color: var(--white); + font-family: var(--font-main); + font-size: 13px; + font-weight: 500; + padding: 5px 14px; + border-radius: 20px; + z-index: 1001; +} + /* ════════════════════════════════════════ 반응형 — 태블릿 (768px 이하) ════════════════════════════════════════ */ @@ -682,8 +834,9 @@ font-size: 13px; } - .commentImage { - max-width: 100%; + .commentImages:has(.commentImage:nth-child(2)) .commentImage { + width: calc(50% - 4px); + max-width: calc(50% - 4px); } /* ── 댓글 수정 ── */ @@ -701,4 +854,35 @@ bottom: 12px; padding: 6px 10px; } -} + + /* ── 라이트박스 ── */ + .lightboxOverlay { + padding: 16px; + } + + .lightboxCloseBtn { + top: 12px; + right: 12px; + width: 38px; + height: 38px; + } + + .lightboxPrevBtn, + .lightboxNextBtn { + width: 38px; + height: 38px; + } + + .lightboxPrevBtn { + left: 8px; + } + + .lightboxNextBtn { + right: 8px; + } + + .lightboxCounter { + bottom: 16px; + font-size: 12px; + } +} \ No newline at end of file diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 3f56a65..7f0e696 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -610,6 +610,23 @@ function QnAListPage() { setImagePreviews(next.map(f => URL.createObjectURL(f))); }; + const handleNewQuestionPaste = (e) => { + if (isStaff) return; // 이해도 체크 입력창에는 이미지 첨부 없음 + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + const merged = [...selectedImages, file].slice(0, 5); + setSelectedImages(merged); + setImagePreviews(merged.map(f => URL.createObjectURL(f))); + } + break; + } + } + }; + // ── 새 질문 등록 ───────────────────────────────── const handleNewQuestion = async () => { const text = newQuestion.trim(); @@ -945,6 +962,7 @@ function QnAListPage() { onKeyDown={e => { if (e.key === 'Enter') isStaff ? handleNewUnderstandCheck() : handleNewQuestion(); }} + onPaste={handleNewQuestionPaste} disabled={isSubmitting} />
{showDetail && ( -
- 세션 자료 +
{session.sessionMaterialUrl - ? {session.sessionMaterialName || '링크'} - : {session.sessionMaterialName || '-'} + ? 세션 자료{session.sessionMaterialName || '링크'} + : {session.sessionMaterialName || ''} }
)} {showRecording && (
{session.recordingUrl - ? 녹화본 - : - + ? 녹화본 {session.recordingPassword && PW : {session.recordingPassword}} + : } - {session.recordingPassword && PW : {session.recordingPassword}}
)}
diff --git a/frontend/src/pages/curriculum/CurriculumPage.module.css b/frontend/src/pages/curriculum/CurriculumPage.module.css index 18208af..e53fec9 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.module.css +++ b/frontend/src/pages/curriculum/CurriculumPage.module.css @@ -187,15 +187,19 @@ white-space: nowrap; } -.sessionDetailRow { +.sessionDetailRow:hover .sessionDetailLabel, +.sessionDetailRow:hover .sessionLink, +.sessionDetailRow:hover .sessionPw, +.sessionDetailRow:hover .sessionDetailVal { + color: var(--dark); + transition: color 0.2s ease-in-out; +} +.sessionLink { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 3px 0 3px 26px; -} - -.sessionLink { font-family: var(--font-main); font-size: 0.9rem; color: var(--black); @@ -242,10 +246,12 @@ font-family: var(--font-main); font-size: 0.9rem; color: var(--black); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; text-align: right; + flex-wrap: nowrap; + overflow: hidden; } /* 과제 */ From 35b1c8cd705e4aec65b0462002b2a8aace897d29 Mon Sep 17 00:00:00 2001 From: lilyyang0077 Date: Thu, 18 Jun 2026 19:07:54 +0900 Subject: [PATCH 17/19] =?UTF-8?q?feat:=20=EC=9E=90=EB=8F=99=20=EB=B3=B4?= =?UTF-8?q?=EC=A6=9D=EA=B8=88=20=EC=9E=AC=EB=A1=9C=EB=94=A9=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/pirocheck/students/StudentDetail.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/src/pages/pirocheck/students/StudentDetail.js b/frontend/src/pages/pirocheck/students/StudentDetail.js index 13afcff..b74d597 100644 --- a/frontend/src/pages/pirocheck/students/StudentDetail.js +++ b/frontend/src/pages/pirocheck/students/StudentDetail.js @@ -120,6 +120,19 @@ function StudentDetail() { const [data, setData] = useState(null); const [defence, setDefence] = useState(''); + // 보증금 정보 새로고침 함수 + const refreshDeposit = async () => { + const depositRes = await authFetch(`/api/deposit/${userId}/deposit/view`); + const depositData = await depositRes.json(); + + setDefence(depositData.ascentDefence.toString()); + + setData(prev => ({ + ...prev, + deposit: depositData, + })); + }; + useEffect(() => { const fetchData = async () => { @@ -166,6 +179,9 @@ function StudentDetail() { method: 'PATCH', body: JSON.stringify({ ascentDefence: Number(defence) }), }); + + await refreshDeposit(); + alert('저장됐습니다!'); }; @@ -214,6 +230,7 @@ function StudentDetail() { submitted: a.submitted, })), }; + return authFetch(`/api/admin/users/${userId}/weeks/${w.week}`, { method: 'PATCH', body: JSON.stringify(body), @@ -222,6 +239,9 @@ function StudentDetail() { ) ) ); + + await refreshDeposit(); + alert('저장됐습니다!'); }; From c85bce305bb67307330b25fbfad0a732eb977626 Mon Sep 17 00:00:00 2001 From: lilyyang0077 Date: Thu, 18 Jun 2026 19:09:19 +0900 Subject: [PATCH 18/19] =?UTF-8?q?feat:=20=EC=9A=94=EC=9D=BC=EB=B3=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/pirocheck/students/StudentDetail.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/pirocheck/students/StudentDetail.js b/frontend/src/pages/pirocheck/students/StudentDetail.js index b74d597..ac275f8 100644 --- a/frontend/src/pages/pirocheck/students/StudentDetail.js +++ b/frontend/src/pages/pirocheck/students/StudentDetail.js @@ -99,7 +99,7 @@ function WeekBlock({ weekData, onChange }) {
)} - + {/* */}
)} From 0c6b3947164be3bfb73a80137b2ba0d015ecaa78 Mon Sep 17 00:00:00 2001 From: lilyyang0077 Date: Thu, 18 Jun 2026 19:15:39 +0900 Subject: [PATCH 19/19] =?UTF-8?q?style:=20=EC=B6=9C=EC=84=9D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=9D=BC=EB=B2=A8=20=ED=91=9C=EC=8B=9C=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/pirocheck/students/StudentDetail.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/pirocheck/students/StudentDetail.js b/frontend/src/pages/pirocheck/students/StudentDetail.js index ac275f8..c6b2685 100644 --- a/frontend/src/pages/pirocheck/students/StudentDetail.js +++ b/frontend/src/pages/pirocheck/students/StudentDetail.js @@ -62,7 +62,7 @@ function WeekBlock({ weekData, onChange }) {
{day.attendances.map((att, j) => (
- {att.attendanceOrder} + {att.attendanceOrder}차