Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ public ResponseEntity<ApiResponse<QuestionResDTO.StatusUpdateRes>> updateQuestio
questionService.updateQuestionStatus(questionId, userId));
}

// 운영진 질문 확인 처리 (관리자 전용)
// 부원이 상세 페이지를 조회하는 GET /api/questions/{questionId}와 별개로 동작한다.
// POST /api/questions/{questionId}/admin-check
@PostMapping("/api/questions/{questionId}/admin-check")
public ResponseEntity<ApiResponse<QuestionResDTO.AdminCheckRes>> checkQuestionByAdmin(
@PathVariable Long questionId,
@AuthenticationPrincipal Long userId
) {
return ResponseUtil.success(QuestionSuccessCode.QUESTION_ADMIN_CHECKED,
questionService.checkQuestionByAdmin(questionId, userId));
}

// 댓글 수정
// PATCH /api/comments/{commentId}
@PatchMapping("/api/comments/{commentId}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public static class CreateRes {
private Long id;
private String content;
private Boolean isSolved;
private Boolean isNew;
private Integer likeCount;
private LocalDateTime createdAt;

Expand All @@ -25,6 +26,7 @@ public static CreateRes from(Question question) {
.id(question.getId())
.content(question.getContent())
.isSolved(question.getIsResolved())
.isNew(question.getAdminCheckedAt() == null)
.likeCount(question.getLikeCount())
.createdAt(question.getCreatedAt())
.build();
Expand Down Expand Up @@ -108,6 +110,14 @@ public record StatusUpdateRes(
) {
}

// 운영진 질문 확인 응답. 확인된 질문은 더 이상 NEW 표시 대상이 아니다.
public record AdminCheckRes(
Long questionId,
Boolean isNew,
LocalDateTime adminCheckedAt
) {
}

// 질문 방 전체 응답
public record QuestionRoomResponse(
SessionResponse session,
Expand Down Expand Up @@ -169,6 +179,8 @@ public record QuestionSummaryResponse(
Boolean isPopular,
Boolean isLiked,
Boolean isMine,
// 운영진이 아직 확인하지 않은 질문이면 true. 부원이 읽어도 이 값은 바뀌지 않는다.
Boolean isNew,
Integer likeCount,
Integer commentCount,
// 댓글이 없으면 빈 배열로 내려가며, 프론트는 빈 배열일 때 미리보기 영역을 숨긴다.
Expand Down Expand Up @@ -250,6 +262,8 @@ public record QuestionCreatedEvent(
String content,
// 이미지 여러 장 지원
List<String> imageUrls,
// 운영진이 아직 확인하지 않은 새 질문이면 true
Boolean isNew,
// 좋아요 수 (생성 직후에는 0)
Integer likeCount,
// 댓글 수 (생성 직후에는 0)
Expand All @@ -271,6 +285,16 @@ public record QuestionUpdatedEvent(
) {
}

// 운영진이 질문을 확인했을 때 SSE로 내려가는 이벤트. 프론트는 이 이벤트로 NEW 표시를 제거한다.
public record QuestionCheckedEvent(
String type,
Long sessionId,
Long questionId,
Boolean isNew,
LocalDateTime adminCheckedAt
) {
}

// 운영진이 이해도 체크를 생성했을 때 SSE로 내려가는 이벤트.
// 같은 세션 질문방을 보고 있는 모든 클라이언트에게 전파된다.
public record UnderstandingCheckCreatedEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ public class Question {
@Column(name = "deleted_at")
private LocalDateTime deletedAt;

@Column(name = "admin_checked_at")
private LocalDateTime adminCheckedAt;

@Column(name = "admin_checked_by")
private Long adminCheckedBy;

// 이미지 URL 목록 조회 (JSON 배열 → List<String> 변환)
@Transient
public List<String> getImageUrls() {
Expand Down Expand Up @@ -108,6 +114,18 @@ public void markResolved() {
this.updatedAt = LocalDateTime.now();
}

// 운영진이 질문을 확인했음을 기록한다. 이미 확인한 질문이면 기존 확인 정보를 유지한다.
public boolean markAdminChecked(Long adminId) {
if (this.adminCheckedAt != null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
this.adminCheckedAt = now;
this.adminCheckedBy = adminId;
this.updatedAt = now;
return true;
}

// JSON 배열 문자열 파싱 유틸 (하위 호환: 기존 단일 URL도 1개짜리 리스트로 반환)
public static List<String> parseImageUrls(String raw) {
if (raw == null || raw.isBlank()) {
Expand Down Expand Up @@ -140,4 +158,4 @@ public static String serializeImageUrls(List<String> urls) {
.collect(Collectors.joining(","));
return "[" + joined + "]";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public enum QuestionSuccessCode implements BaseCode {
QUESTION_UPDATED(HttpStatus.OK, "QUESTION200_5", "질문이 수정되었습니다."),
QUESTION_DELETED(HttpStatus.OK, "QUESTION200_6", "질문이 삭제되었습니다."),
QUESTION_STATUS_UPDATED(HttpStatus.OK, "QUESTION200_7", "질문 상태가 변경되었습니다."),
QUESTION_ADMIN_CHECKED(HttpStatus.OK, "QUESTION200_10", "질문 확인 처리가 완료되었습니다."),
QUESTION_CREATED(HttpStatus.CREATED, "QUESTION201_1", "질문이 등록되었습니다."),
COMMENT_CREATED(HttpStatus.CREATED, "QUESTION201_2", "댓글이 등록되었습니다."),
COMMENT_UPDATED(HttpStatus.OK, "QUESTION200_8", "댓글이 수정되었습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ public void publishQuestionUpdated(Long sessionId, QuestionResDTO.QuestionUpdate
broadcast(sessionId, "question-updated", event);
}

// 운영진 확인 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다.
public void publishQuestionChecked(Long sessionId, QuestionResDTO.QuestionCheckedEvent event) {
broadcast(sessionId, "question-checked", event);
}

// 이해도 체크 생성 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다.
public void publishUnderstandingCheckCreated(Long sessionId, QuestionResDTO.UnderstandingCheckCreatedEvent event) {
broadcast(sessionId, "understanding-check-created", event);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ public QuestionResDTO.UpdateDeleteRes updateQuestion(
public QuestionResDTO.UpdateDeleteRes deleteQuestion(Long questionId, Long userId) {
User loginUser = findLoginUser(userId);
Question question = findQuestion(questionId);
validateQuestionOwner(question, loginUser);
validateQuestionDeletePermission(question, loginUser);

question.softDelete();

Expand Down Expand Up @@ -450,6 +450,26 @@ public QuestionResDTO.StatusUpdateRes updateQuestionStatus(Long questionId, Long
);
}

// 운영진 질문 확인 처리
// POST /api/questions/{questionId}/admin-check
@Transactional
public QuestionResDTO.AdminCheckRes checkQuestionByAdmin(Long questionId, Long userId) {
User loginUser = findLoginUser(userId);
validateAdmin(loginUser);

Question question = findQuestion(questionId);
boolean firstChecked = question.markAdminChecked(loginUser.getId());
if (firstChecked) {
publishQuestionCheckedEventAfterCommit(question);
}

return new QuestionResDTO.AdminCheckRes(
question.getId(),
question.getAdminCheckedAt() == null,
question.getAdminCheckedAt()
);
}

// 이해도 체크 생성
@Transactional
public QuestionResDTO.UnderstandingCheckCreateResponse createUnderstandingCheck(
Expand Down Expand Up @@ -557,10 +577,17 @@ private void validateCheckBelongsToSession(UnderstandingCheck check, StudySessio

private void validateQuestionOwner(Question question, User loginUser) {
if (!question.getUser().getId().equals(loginUser.getId())) {
throw new QuestionException(HttpStatus.FORBIDDEN, "본인의 질문만 수정/삭제할 수 있습니다.");
throw new QuestionException(HttpStatus.FORBIDDEN, "본인의 질문만 수정할 수 있습니다.");
}
}

private void validateQuestionDeletePermission(Question question, User loginUser) {
if (loginUser.getRole() == Role.ADMIN || question.getUser().getId().equals(loginUser.getId())) {
return;
}
throw new QuestionException(HttpStatus.FORBIDDEN, "본인의 질문만 삭제할 수 있습니다.");
}

private void validateCommentOwner(QuestionComment comment, User loginUser) {
if (!comment.getUser().getId().equals(loginUser.getId())) {
throw new QuestionException(HttpStatus.FORBIDDEN, "본인의 댓글만 수정/삭제할 수 있습니다.");
Expand Down Expand Up @@ -722,6 +749,7 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse (
!question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD,
isLiked,
isMine,
question.getAdminCheckedAt() == null,
question.getLikeCount(),
summaryContext.commentCounts().getOrDefault(questionId, 0),
// 목록 화면은 최상위 댓글 중 먼저 달린 3개만 미리보기로 보여준다.
Expand Down Expand Up @@ -854,6 +882,7 @@ private void publishQuestionCreatedEventAfterCommit(Question question) {
question.getId(),
question.getContent(),
question.getImageUrls(),
question.getAdminCheckedAt() == null,
question.getLikeCount(),
0, // 방금 만들어진 질문이므로 댓글 수는 0
question.getCreatedAt()
Expand All @@ -879,6 +908,20 @@ private void publishQuestionUpdatedEventAfterCommit(Question question, boolean i
publishAfterCommit(() -> questionEventService.publishQuestionUpdated(sessionId, event));
}

private void publishQuestionCheckedEventAfterCommit(Question question) {
Long sessionId = question.getSession().getId();

QuestionResDTO.QuestionCheckedEvent event = new QuestionResDTO.QuestionCheckedEvent(
"QUESTION_CHECKED",
sessionId,
question.getId(),
false,
question.getAdminCheckedAt()
);

publishAfterCommit(() -> questionEventService.publishQuestionChecked(sessionId, event));
}

private void publishUnderstandingCheckCreatedEventAfterCommit(
Long sessionId, UnderstandingCheck check, int attendanceCount
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

.requestMatchers(HttpMethod.POST, "/api/sessions/{sessionId}/understanding-checks").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/questions/{questionId}/status").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/questions/{questionId}/admin-check").hasRole("ADMIN")

// 나머지는 로그인한 사용자면 접근 가능
.anyRequest().authenticated()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ALTER TABLE question
ADD COLUMN admin_checked_at TIMESTAMP NULL,
ADD COLUMN admin_checked_by BIGINT NULL;

-- 기존 질문은 운영진이 이미 확인한 것으로 처리하고,
-- 이후 새로 생성되는 질문만 admin_checked_at = NULL 상태로 남겨 NEW 표시 대상으로 삼는다.
UPDATE question
SET admin_checked_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)
WHERE admin_checked_at IS NULL;
Loading
Loading