Skip to content

Commit 9b13ccb

Browse files
authored
Merge pull request #256 from pirogramming/feat/#255
[Feat] 운영진 질문 확인 및 신규 질문 표시 기능 구현
2 parents 7f847c4 + 77894fa commit 9b13ccb

12 files changed

Lines changed: 230 additions & 31 deletions

File tree

backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ public ResponseEntity<ApiResponse<QuestionResDTO.StatusUpdateRes>> updateQuestio
124124
questionService.updateQuestionStatus(questionId, userId));
125125
}
126126

127+
// 운영진 질문 확인 처리 (관리자 전용)
128+
// 부원이 상세 페이지를 조회하는 GET /api/questions/{questionId}와 별개로 동작한다.
129+
// POST /api/questions/{questionId}/admin-check
130+
@PostMapping("/api/questions/{questionId}/admin-check")
131+
public ResponseEntity<ApiResponse<QuestionResDTO.AdminCheckRes>> checkQuestionByAdmin(
132+
@PathVariable Long questionId,
133+
@AuthenticationPrincipal Long userId
134+
) {
135+
return ResponseUtil.success(QuestionSuccessCode.QUESTION_ADMIN_CHECKED,
136+
questionService.checkQuestionByAdmin(questionId, userId));
137+
}
138+
127139
// 댓글 수정
128140
// PATCH /api/comments/{commentId}
129141
@PatchMapping("/api/comments/{commentId}")

backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public static class CreateRes {
1717
private Long id;
1818
private String content;
1919
private Boolean isSolved;
20+
private Boolean isNew;
2021
private Integer likeCount;
2122
private LocalDateTime createdAt;
2223

@@ -25,6 +26,7 @@ public static CreateRes from(Question question) {
2526
.id(question.getId())
2627
.content(question.getContent())
2728
.isSolved(question.getIsResolved())
29+
.isNew(question.getAdminCheckedAt() == null)
2830
.likeCount(question.getLikeCount())
2931
.createdAt(question.getCreatedAt())
3032
.build();
@@ -108,6 +110,14 @@ public record StatusUpdateRes(
108110
) {
109111
}
110112

113+
// 운영진 질문 확인 응답. 확인된 질문은 더 이상 NEW 표시 대상이 아니다.
114+
public record AdminCheckRes(
115+
Long questionId,
116+
Boolean isNew,
117+
LocalDateTime adminCheckedAt
118+
) {
119+
}
120+
111121
// 질문 방 전체 응답
112122
public record QuestionRoomResponse(
113123
SessionResponse session,
@@ -169,6 +179,8 @@ public record QuestionSummaryResponse(
169179
Boolean isPopular,
170180
Boolean isLiked,
171181
Boolean isMine,
182+
// 운영진이 아직 확인하지 않은 질문이면 true. 부원이 읽어도 이 값은 바뀌지 않는다.
183+
Boolean isNew,
172184
Integer likeCount,
173185
Integer commentCount,
174186
// 댓글이 없으면 빈 배열로 내려가며, 프론트는 빈 배열일 때 미리보기 영역을 숨긴다.
@@ -250,6 +262,8 @@ public record QuestionCreatedEvent(
250262
String content,
251263
// 이미지 여러 장 지원
252264
List<String> imageUrls,
265+
// 운영진이 아직 확인하지 않은 새 질문이면 true
266+
Boolean isNew,
253267
// 좋아요 수 (생성 직후에는 0)
254268
Integer likeCount,
255269
// 댓글 수 (생성 직후에는 0)
@@ -271,6 +285,16 @@ public record QuestionUpdatedEvent(
271285
) {
272286
}
273287

288+
// 운영진이 질문을 확인했을 때 SSE로 내려가는 이벤트. 프론트는 이 이벤트로 NEW 표시를 제거한다.
289+
public record QuestionCheckedEvent(
290+
String type,
291+
Long sessionId,
292+
Long questionId,
293+
Boolean isNew,
294+
LocalDateTime adminCheckedAt
295+
) {
296+
}
297+
274298
// 운영진이 이해도 체크를 생성했을 때 SSE로 내려가는 이벤트.
275299
// 같은 세션 질문방을 보고 있는 모든 클라이언트에게 전파된다.
276300
public record UnderstandingCheckCreatedEvent(

backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ public class Question {
5858
@Column(name = "deleted_at")
5959
private LocalDateTime deletedAt;
6060

61+
@Column(name = "admin_checked_at")
62+
private LocalDateTime adminCheckedAt;
63+
64+
@Column(name = "admin_checked_by")
65+
private Long adminCheckedBy;
66+
6167
// 이미지 URL 목록 조회 (JSON 배열 → List<String> 변환)
6268
@Transient
6369
public List<String> getImageUrls() {
@@ -108,6 +114,18 @@ public void markResolved() {
108114
this.updatedAt = LocalDateTime.now();
109115
}
110116

117+
// 운영진이 질문을 확인했음을 기록한다. 이미 확인한 질문이면 기존 확인 정보를 유지한다.
118+
public boolean markAdminChecked(Long adminId) {
119+
if (this.adminCheckedAt != null) {
120+
return false;
121+
}
122+
LocalDateTime now = LocalDateTime.now();
123+
this.adminCheckedAt = now;
124+
this.adminCheckedBy = adminId;
125+
this.updatedAt = now;
126+
return true;
127+
}
128+
111129
// JSON 배열 문자열 파싱 유틸 (하위 호환: 기존 단일 URL도 1개짜리 리스트로 반환)
112130
public static List<String> parseImageUrls(String raw) {
113131
if (raw == null || raw.isBlank()) {
@@ -140,4 +158,4 @@ public static String serializeImageUrls(List<String> urls) {
140158
.collect(Collectors.joining(","));
141159
return "[" + joined + "]";
142160
}
143-
}
161+
}

backend/src/main/java/com/example/Piroin/project/domain/question/exception/code/QuestionSuccessCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public enum QuestionSuccessCode implements BaseCode {
1616
QUESTION_UPDATED(HttpStatus.OK, "QUESTION200_5", "질문이 수정되었습니다."),
1717
QUESTION_DELETED(HttpStatus.OK, "QUESTION200_6", "질문이 삭제되었습니다."),
1818
QUESTION_STATUS_UPDATED(HttpStatus.OK, "QUESTION200_7", "질문 상태가 변경되었습니다."),
19+
QUESTION_ADMIN_CHECKED(HttpStatus.OK, "QUESTION200_10", "질문 확인 처리가 완료되었습니다."),
1920
QUESTION_CREATED(HttpStatus.CREATED, "QUESTION201_1", "질문이 등록되었습니다."),
2021
COMMENT_CREATED(HttpStatus.CREATED, "QUESTION201_2", "댓글이 등록되었습니다."),
2122
COMMENT_UPDATED(HttpStatus.OK, "QUESTION200_8", "댓글이 수정되었습니다."),

backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ public void publishQuestionUpdated(Long sessionId, QuestionResDTO.QuestionUpdate
6060
broadcast(sessionId, "question-updated", event);
6161
}
6262

63+
// 운영진 확인 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다.
64+
public void publishQuestionChecked(Long sessionId, QuestionResDTO.QuestionCheckedEvent event) {
65+
broadcast(sessionId, "question-checked", event);
66+
}
67+
6368
// 이해도 체크 생성 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다.
6469
public void publishUnderstandingCheckCreated(Long sessionId, QuestionResDTO.UnderstandingCheckCreatedEvent event) {
6570
broadcast(sessionId, "understanding-check-created", event);

backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ public QuestionResDTO.UpdateDeleteRes updateQuestion(
382382
public QuestionResDTO.UpdateDeleteRes deleteQuestion(Long questionId, Long userId) {
383383
User loginUser = findLoginUser(userId);
384384
Question question = findQuestion(questionId);
385-
validateQuestionOwner(question, loginUser);
385+
validateQuestionDeletePermission(question, loginUser);
386386

387387
question.softDelete();
388388

@@ -450,6 +450,26 @@ public QuestionResDTO.StatusUpdateRes updateQuestionStatus(Long questionId, Long
450450
);
451451
}
452452

453+
// 운영진 질문 확인 처리
454+
// POST /api/questions/{questionId}/admin-check
455+
@Transactional
456+
public QuestionResDTO.AdminCheckRes checkQuestionByAdmin(Long questionId, Long userId) {
457+
User loginUser = findLoginUser(userId);
458+
validateAdmin(loginUser);
459+
460+
Question question = findQuestion(questionId);
461+
boolean firstChecked = question.markAdminChecked(loginUser.getId());
462+
if (firstChecked) {
463+
publishQuestionCheckedEventAfterCommit(question);
464+
}
465+
466+
return new QuestionResDTO.AdminCheckRes(
467+
question.getId(),
468+
question.getAdminCheckedAt() == null,
469+
question.getAdminCheckedAt()
470+
);
471+
}
472+
453473
// 이해도 체크 생성
454474
@Transactional
455475
public QuestionResDTO.UnderstandingCheckCreateResponse createUnderstandingCheck(
@@ -557,10 +577,17 @@ private void validateCheckBelongsToSession(UnderstandingCheck check, StudySessio
557577

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

584+
private void validateQuestionDeletePermission(Question question, User loginUser) {
585+
if (loginUser.getRole() == Role.ADMIN || question.getUser().getId().equals(loginUser.getId())) {
586+
return;
587+
}
588+
throw new QuestionException(HttpStatus.FORBIDDEN, "본인의 질문만 삭제할 수 있습니다.");
589+
}
590+
564591
private void validateCommentOwner(QuestionComment comment, User loginUser) {
565592
if (!comment.getUser().getId().equals(loginUser.getId())) {
566593
throw new QuestionException(HttpStatus.FORBIDDEN, "본인의 댓글만 수정/삭제할 수 있습니다.");
@@ -722,6 +749,7 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse (
722749
!question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD,
723750
isLiked,
724751
isMine,
752+
question.getAdminCheckedAt() == null,
725753
question.getLikeCount(),
726754
summaryContext.commentCounts().getOrDefault(questionId, 0),
727755
// 목록 화면은 최상위 댓글 중 먼저 달린 3개만 미리보기로 보여준다.
@@ -854,6 +882,7 @@ private void publishQuestionCreatedEventAfterCommit(Question question) {
854882
question.getId(),
855883
question.getContent(),
856884
question.getImageUrls(),
885+
question.getAdminCheckedAt() == null,
857886
question.getLikeCount(),
858887
0, // 방금 만들어진 질문이므로 댓글 수는 0
859888
question.getCreatedAt()
@@ -879,6 +908,20 @@ private void publishQuestionUpdatedEventAfterCommit(Question question, boolean i
879908
publishAfterCommit(() -> questionEventService.publishQuestionUpdated(sessionId, event));
880909
}
881910

911+
private void publishQuestionCheckedEventAfterCommit(Question question) {
912+
Long sessionId = question.getSession().getId();
913+
914+
QuestionResDTO.QuestionCheckedEvent event = new QuestionResDTO.QuestionCheckedEvent(
915+
"QUESTION_CHECKED",
916+
sessionId,
917+
question.getId(),
918+
false,
919+
question.getAdminCheckedAt()
920+
);
921+
922+
publishAfterCommit(() -> questionEventService.publishQuestionChecked(sessionId, event));
923+
}
924+
882925
private void publishUnderstandingCheckCreatedEventAfterCommit(
883926
Long sessionId, UnderstandingCheck check, int attendanceCount
884927
) {

backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
6868

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

7273
// 나머지는 로그인한 사용자면 접근 가능
7374
.anyRequest().authenticated()
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
ALTER TABLE question
2+
ADD COLUMN admin_checked_at TIMESTAMP NULL,
3+
ADD COLUMN admin_checked_by BIGINT NULL;
4+
5+
-- 기존 질문은 운영진이 이미 확인한 것으로 처리하고,
6+
-- 이후 새로 생성되는 질문만 admin_checked_at = NULL 상태로 남겨 NEW 표시 대상으로 삼는다.
7+
UPDATE question
8+
SET admin_checked_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)
9+
WHERE admin_checked_at IS NULL;

0 commit comments

Comments
 (0)