diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java index 54505dd..6a7e818 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java @@ -30,6 +30,7 @@ import java.time.DayOfWeek; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; @Service @@ -248,17 +249,21 @@ public StudentWeeklyStatusResponse getStudentWeeklyStatus( List sessions = curriculumRepository.findByWeek(week); - List dayResponses = new ArrayList<>(); + List sessionDates = sessions.stream() + .map(StudySession::getSessionDate) + .distinct() + .sorted() + .toList(); - for (StudySession session : sessions) { + List dayResponses = new ArrayList<>(); - LocalDate sessionDate = session.getSessionDate(); + for (LocalDate sessionDate : sessionDates) { - String day = - sessionDate.getDayOfWeek().toString(); + String day = sessionDate.getDayOfWeek().toString(); /* * 과제 조회 + * 과제 date를 요일/sessionDate 기준으로 조회하는 기존 방식 유지 */ List assignments = assignmentRepository.findBySessionDate(sessionDate); @@ -293,12 +298,14 @@ public StudentWeeklyStatusResponse getStudentWeeklyStatus( /* * 출석 조회 + * attendance는 attendance_code의 날짜 기준으로 계산하는 기존 방식 유지 */ List attendanceCodes = attendanceCodeRepository.findByAttendanceDate(sessionDate); List attendanceResponses = attendanceCodes.stream() + .sorted(Comparator.comparing(AttendanceCode::getAttendanceOrder)) .map(code -> { Attendance attendance = 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/question/entity/QuestionAnonymousIdentity.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java index 94a617e..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.*; @@ -11,12 +12,12 @@ 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"} + name = "uq_question_anonymous_identity_question_role_no", + columnNames = {"question_id", "role", "anonymous_no"} ) } ) @@ -41,7 +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 53448d9..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 @@ -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,14 +18,27 @@ public interface QuestionAnonymousIdentityRepository extends JpaRepository findByQuestionAndUser(Question question, User user); - // 해당 질문에서 특정 역할(MEMBER/ADMIN)의 익명 번호 수 조회 - // 용도: 새 익명 번호 발급 시 역할별로 따로 카운트 - // MEMBER → 익명1, 익명2... / ADMIN → 운영진1, 운영진2... - int countByQuestionAndUser_Role(Question question, Role role); + // 질문 상세 조회용: 댓글 작성자들의 익명 번호를 한 번에 조회 + @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 + ); - @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 ); -} \ 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..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 @@ -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개를 질문별로 한 번에 조회한다. @@ -25,7 +33,7 @@ public interface QuestionCommentRepository extends JpaRepository 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..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,11 @@ 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; import java.util.List; import java.util.Optional; @@ -19,4 +23,28 @@ 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); + + /* + 좋아요 카운트 갱신용: 같은 질문에 대한 동시 토글 요청을 직렬화해 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 008597d..1c6e6b3 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,72 @@ 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 anonymousIdentitiesByUserId = new HashMap<>(); + if (!anonymousUserIds.isEmpty()) { + anonymousIdentityRepository.findByQuestionAndUserIds(question, anonymousUserIds) + .forEach(identity -> anonymousIdentitiesByUserId.put( + identity.getUser().getId(), + new AnonymousIdentityDisplay(identity.getRole(), identity.getAnonymousNo()) + )); + } - 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() - )) + return new DetailCommentContext(topComments, repliesByParentId, anonymousIdentitiesByUserId); + } + + 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.anonymousIdentitiesByUserId()), 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.anonymousIdentitiesByUserId()), + 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()); } @@ -131,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); @@ -212,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 @@ -222,6 +269,7 @@ private String assignAnonymousIdentity(Question question, User commenter) { .question(question) .user(commenter) .anonymousNo(nextNo) + .role(commenter.getRole()) .createdAt(LocalDateTime.now()) .build()); @@ -234,15 +282,20 @@ 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 anonymousIdentitiesByUserId + ) { 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 ? "운영진" : "익명"); + + AnonymousIdentityDisplay identity = anonymousIdentitiesByUserId.get(commenter.getId()); + if (identity == null) { + return commenter.getRole() == Role.ADMIN ? "운영진" : "익명"; + } + return buildDisplayName(identity.role(), identity.anonymousNo()); } // 질문 등록 @@ -278,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 = findQuestion(questionId); + Question question = findQuestionForUpdate(questionId); // 이미 좋아요를 눌렀는지 확인 QuestionResDTO.LikeRes result = questionLikeRepository.findByQuestionAndUser(question, loginUser) @@ -476,6 +529,16 @@ 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 Question findQuestionForUpdate(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, "세션을 찾을 수 없습니다.")); @@ -871,6 +934,19 @@ private record QuestionSummaryContext( ) { } + private record DetailCommentContext( + List topComments, + Map> repliesByParentId, + Map anonymousIdentitiesByUserId + ) { + } + + private record AnonymousIdentityDisplay( + Role role, + Integer anonymousNo + ) { + } + // 질문은 내용 또는 이미지 중 하나는 반드시 있어야 함 private void validateQuestionContent(String content, List imageUrls) { boolean hasContent = content != null && !content.isBlank(); 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 +} 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; 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); diff --git a/frontend/src/pages/curriculum/CurriculumPage.js b/frontend/src/pages/curriculum/CurriculumPage.js index 79a15a1..fc7fe89 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.js +++ b/frontend/src/pages/curriculum/CurriculumPage.js @@ -35,21 +35,19 @@ function SessionInfo({ session, isAdmin }) { {showDetail && {session.hostName}} {showDetail && ( -
- 세션 자료 +
{session.sessionMaterialUrl - ? {session.sessionMaterialName || '링크'} - : {session.sessionMaterialName || '-'} + ? 세션 자료{session.sessionMaterialName || '링크'} + : {session.sessionMaterialName || ''} }
)} {showRecording && (
{session.recordingUrl - ? 녹화본 - : - + ? 녹화본 {session.recordingPassword && PW : {session.recordingPassword}} + : } - {session.recordingPassword && PW : {session.recordingPassword}}
)}
@@ -73,7 +71,7 @@ function MemberSessionCard({ day }) { toggle -
+
{isOpen && (
@@ -110,7 +108,7 @@ function AdminSessionCard({ day, onEdit, onDelete }) {
toggle -
+
{isOpen && (
@@ -168,7 +166,7 @@ function SessionForm({ day, week, onClose, onSave }) { const date = new Date(year, month - 1, day); const map = { 0: '일요일', 1: '월요일', 2: '화요일', 3: '수요일', 4: '목요일', 5: '금요일', 6: '토요일' }; return map[date.getDay()] || ''; - }; + }; const handleSave = async () => { const newErrors = {}; @@ -233,6 +231,7 @@ function SessionForm({ day, week, onClose, onSave }) { return (
+
setForm({ ...form, assignmentName: e.target.value })} />
-
setForm({ ...form, assignmentUrl: e.target.value })} />
+
setForm({ ...form, assignmentName: e.target.value })} />
+
setForm({ ...form, assignmentUrl: e.target.value })} />
@@ -330,7 +330,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(); }, []); @@ -364,7 +364,7 @@ function CurriculumPage() { 세션 생성
- )} + )} {Object.entries(grouped).map(([week, weekDays]) => (
@@ -387,8 +387,8 @@ function CurriculumPage() { ))} {showForm && ( - - { setShowForm(false); setEditDay(null); setCreateWeek(null); }} 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; } /* 과제 */ diff --git a/frontend/src/pages/pirocheck/PIroCheckMain.js b/frontend/src/pages/pirocheck/PIroCheckMain.js index 092f8c5..546c6c2 100644 --- a/frontend/src/pages/pirocheck/PIroCheckMain.js +++ b/frontend/src/pages/pirocheck/PIroCheckMain.js @@ -13,9 +13,9 @@ function PIroCheckMain() { ]; const memberMenus = [ - { label: 'ATTENDANCE CHECK', path: '/pirocheck/attendance' }, - { label: 'ASSIGNMENT CHECK', path: '/pirocheck/assignment' }, - { label: 'DEPOSIT CHECK', path: '/pirocheck/deposit' }, + { label: '출석 체크', path: '/pirocheck/attendance' }, + { label: '과제 체크', path: '/pirocheck/assignment' }, + { label: '보증금 체크', path: '/pirocheck/deposit' }, ]; const menus = role === 'ADMIN' ? adminMenus : memberMenus; diff --git a/frontend/src/pages/pirocheck/PIroCheckMain.module.css b/frontend/src/pages/pirocheck/PIroCheckMain.module.css index 856186b..cb47066 100644 --- a/frontend/src/pages/pirocheck/PIroCheckMain.module.css +++ b/frontend/src/pages/pirocheck/PIroCheckMain.module.css @@ -15,7 +15,7 @@ border: none; border-radius: 10px; color: var(--main); - font-family: var(--font-title); + font-family: var(--font-main); font-size: 2rem; font-weight: 800; cursor: pointer; 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(''); diff --git a/frontend/src/pages/pirocheck/students/StudentDetail.js b/frontend/src/pages/pirocheck/students/StudentDetail.js index d3e5c17..c6b2685 100644 --- a/frontend/src/pages/pirocheck/students/StudentDetail.js +++ b/frontend/src/pages/pirocheck/students/StudentDetail.js @@ -10,8 +10,8 @@ import Toggle2 from '../../../assets/images/icon_togle2.svg'; const IS_MOCK = false; const dayLabel = { TUESDAY: 'TUE', THURSDAY: 'THU', SATURDAY: 'SAT' }; -const statusOptions = ['SUBMITTED', 'LATE', 'NOT_SUBMITTED']; -const statusLabel = { SUBMITTED: '성공', LATE: '미달', NOT_SUBMITTED: '실패' }; +const statusOptions = ['PENDING', 'SUCCESS', 'INSUFFICIENT', 'FAILURE']; +const statusLabel = { PENDING: '채점 중', SUCCESS: '성공', INSUFFICIENT: '미달', FAILURE: '실패' }; function WeekBlock({ weekData, onChange }) { const [isOpen, setIsOpen] = useState(false); @@ -62,7 +62,7 @@ function WeekBlock({ weekData, onChange }) {
{day.attendances.map((att, j) => (
- {att.attendanceOrder} + {att.attendanceOrder}차