From 35b5a200b37d2fc788cc2fa433e5d966cd5d368a Mon Sep 17 00:00:00 2001 From: issuejong Date: Wed, 24 Jun 2026 01:43:04 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[Feat]=20=EC=A7=88=EB=AC=B8=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EC=A7=84=20=ED=99=95=EC=9D=B8=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/entity/Question.java | 8 ++++++- .../V11__add_admin_check_to_question.sql | 9 +++++++ frontend/package-lock.json | 24 ------------------- 3 files changed, 16 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V11__add_admin_check_to_question.sql diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java index 24e3197..a0a872e 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java @@ -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 변환) @Transient public List getImageUrls() { @@ -140,4 +146,4 @@ public static String serializeImageUrls(List urls) { .collect(Collectors.joining(",")); return "[" + joined + "]"; } -} \ No newline at end of file +} diff --git a/backend/src/main/resources/db/migration/V11__add_admin_check_to_question.sql b/backend/src/main/resources/db/migration/V11__add_admin_check_to_question.sql new file mode 100644 index 0000000..8271d1d --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__add_admin_check_to_question.sql @@ -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; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1b6c692..6e43dfa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -68,7 +68,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -718,7 +717,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1602,7 +1600,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", @@ -3345,7 +3342,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3823,7 +3819,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -3877,7 +3872,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -4247,7 +4241,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4346,7 +4339,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5301,7 +5293,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7135,7 +7126,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9902,7 +9892,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -10800,7 +10789,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12168,7 +12156,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13303,7 +13290,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13681,7 +13667,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13813,7 +13798,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13847,7 +13831,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14346,7 +14329,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -14589,7 +14571,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15960,7 +15941,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16129,7 +16109,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -16559,7 +16538,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -16630,7 +16608,6 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -17052,7 +17029,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", From 910188ade5c1149ead9be581b73387377fceff89 Mon Sep 17 00:00:00 2001 From: issuejong Date: Wed, 24 Jun 2026 01:44:50 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[Feat]=20=EC=A7=88=EB=AC=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EC=8B=A0=EA=B7=9C=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Piroin/project/domain/question/dto/QuestionResDTO.java | 2 ++ .../Piroin/project/domain/question/service/QuestionService.java | 1 + 2 files changed, 3 insertions(+) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java index 7c516fd..3db9ddb 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java @@ -169,6 +169,8 @@ public record QuestionSummaryResponse( Boolean isPopular, Boolean isLiked, Boolean isMine, + // 운영진이 아직 확인하지 않은 질문이면 true. 부원이 읽어도 이 값은 바뀌지 않는다. + Boolean isNew, Integer likeCount, Integer commentCount, // 댓글이 없으면 빈 배열로 내려가며, 프론트는 빈 배열일 때 미리보기 영역을 숨긴다. 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 1c6e6b3..1e905fd 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 @@ -722,6 +722,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개만 미리보기로 보여준다. From 3083697c7d9310fae093146dc008b8a8264d05d9 Mon Sep 17 00:00:00 2001 From: issuejong Date: Wed, 24 Jun 2026 01:48:59 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[Feat]=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=ED=99=95=EC=9D=B8=20API=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 --- .../question/controller/QuestionController.java | 12 ++++++++++++ .../domain/question/dto/QuestionResDTO.java | 8 ++++++++ .../domain/question/entity/Question.java | 11 +++++++++++ .../exception/code/QuestionSuccessCode.java | 1 + .../question/service/QuestionService.java | 17 +++++++++++++++++ .../project/global/config/SecurityConfig.java | 1 + 6 files changed, 50 insertions(+) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java index 97952d3..3dfcd04 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java @@ -124,6 +124,18 @@ public ResponseEntity> updateQuestio questionService.updateQuestionStatus(questionId, userId)); } + // 운영진 질문 확인 처리 (관리자 전용) + // 부원이 상세 페이지를 조회하는 GET /api/questions/{questionId}와 별개로 동작한다. + // POST /api/questions/{questionId}/admin-check + @PostMapping("/api/questions/{questionId}/admin-check") + public ResponseEntity> 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}") diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java index 3db9ddb..afc43b4 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java @@ -108,6 +108,14 @@ public record StatusUpdateRes( ) { } + // 운영진 질문 확인 응답. 확인된 질문은 더 이상 NEW 표시 대상이 아니다. + public record AdminCheckRes( + Long questionId, + Boolean isNew, + LocalDateTime adminCheckedAt + ) { + } + // 질문 방 전체 응답 public record QuestionRoomResponse( SessionResponse session, diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java index a0a872e..b4acb44 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java @@ -114,6 +114,17 @@ public void markResolved() { this.updatedAt = LocalDateTime.now(); } + // 운영진이 질문을 확인했음을 기록한다. 이미 확인한 질문이면 기존 확인 정보를 유지한다. + public void markAdminChecked(Long adminId) { + if (this.adminCheckedAt != null) { + return; + } + LocalDateTime now = LocalDateTime.now(); + this.adminCheckedAt = now; + this.adminCheckedBy = adminId; + this.updatedAt = now; + } + // JSON 배열 문자열 파싱 유틸 (하위 호환: 기존 단일 URL도 1개짜리 리스트로 반환) public static List parseImageUrls(String raw) { if (raw == null || raw.isBlank()) { diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/exception/code/QuestionSuccessCode.java b/backend/src/main/java/com/example/Piroin/project/domain/question/exception/code/QuestionSuccessCode.java index cf90b2d..3be118b 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/exception/code/QuestionSuccessCode.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/exception/code/QuestionSuccessCode.java @@ -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", "댓글이 수정되었습니다."), 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 1e905fd..7e7745d 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 @@ -450,6 +450,23 @@ 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); + question.markAdminChecked(loginUser.getId()); + + return new QuestionResDTO.AdminCheckRes( + question.getId(), + question.getAdminCheckedAt() == null, + question.getAdminCheckedAt() + ); + } + // 이해도 체크 생성 @Transactional public QuestionResDTO.UnderstandingCheckCreateResponse createUnderstandingCheck( diff --git a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java index 1ecce0d..80aa0d1 100644 --- a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java @@ -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() From a7d7487ae180a0770bdf0d1647fdcb75c121a3a0 Mon Sep 17 00:00:00 2001 From: issuejong Date: Wed, 24 Jun 2026 01:53:08 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[Feat]=20=EC=A7=88=EB=AC=B8=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20SSE=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=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 --- .../domain/question/dto/QuestionResDTO.java | 10 ++++++++++ .../domain/question/entity/Question.java | 5 +++-- .../service/QuestionEventService.java | 5 +++++ .../question/service/QuestionService.java | 19 ++++++++++++++++++- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java index afc43b4..673a422 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java @@ -281,6 +281,16 @@ public record QuestionUpdatedEvent( ) { } + // 운영진이 질문을 확인했을 때 SSE로 내려가는 이벤트. 프론트는 이 이벤트로 NEW 표시를 제거한다. + public record QuestionCheckedEvent( + String type, + Long sessionId, + Long questionId, + Boolean isNew, + LocalDateTime adminCheckedAt + ) { + } + // 운영진이 이해도 체크를 생성했을 때 SSE로 내려가는 이벤트. // 같은 세션 질문방을 보고 있는 모든 클라이언트에게 전파된다. public record UnderstandingCheckCreatedEvent( diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java index b4acb44..f27ea08 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java @@ -115,14 +115,15 @@ public void markResolved() { } // 운영진이 질문을 확인했음을 기록한다. 이미 확인한 질문이면 기존 확인 정보를 유지한다. - public void markAdminChecked(Long adminId) { + public boolean markAdminChecked(Long adminId) { if (this.adminCheckedAt != null) { - return; + return false; } LocalDateTime now = LocalDateTime.now(); this.adminCheckedAt = now; this.adminCheckedBy = adminId; this.updatedAt = now; + return true; } // JSON 배열 문자열 파싱 유틸 (하위 호환: 기존 단일 URL도 1개짜리 리스트로 반환) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java index e304fd3..fb2274b 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java @@ -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); 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 7e7745d..7910477 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 @@ -458,7 +458,10 @@ public QuestionResDTO.AdminCheckRes checkQuestionByAdmin(Long questionId, Long u validateAdmin(loginUser); Question question = findQuestion(questionId); - question.markAdminChecked(loginUser.getId()); + boolean firstChecked = question.markAdminChecked(loginUser.getId()); + if (firstChecked) { + publishQuestionCheckedEventAfterCommit(question); + } return new QuestionResDTO.AdminCheckRes( question.getId(), @@ -897,6 +900,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 ) { From cbde63099df7b685e95f044918d228b494f092c1 Mon Sep 17 00:00:00 2001 From: issuejong Date: Wed, 24 Jun 2026 02:02:13 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[Feat]=20Q&A=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=ED=91=9C=EC=8B=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnADetailPage.js | 31 +++++++++++++- frontend/src/pages/qna/QnAListPage.js | 40 ++++++++++++++++++- frontend/src/pages/qna/QnAListPage.module.css | 35 +++++++++++++++- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index 8dbf4af..ef22555 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -75,6 +75,7 @@ function QnADetailPage() { const { sessionId, questionId } = useParams(); const navigate = useNavigate(); const isStaff = localStorage.getItem('role') === 'ADMIN'; + const adminCheckRequestedRef = useRef(new Set()); // ── 질문 / 로딩 상태 ───────────────────────────── const [question, setQuestion] = useState(null); @@ -191,6 +192,34 @@ function QnADetailPage() { } }, [questionId, fetchQuestion]); + const markQuestionCheckedByAdmin = useCallback(async () => { + if (!isStaff || !questionId) return; + + const requestKey = String(questionId); + if (adminCheckRequestedRef.current.has(requestKey)) return; + adminCheckRequestedRef.current.add(requestKey); + + try { + const res = await authFetch(`/api/questions/${questionId}/admin-check`, { method: 'POST' }); + if (!res.ok) throw new Error(`서버 오류: ${res.status}`); + const json = await res.json(); + if (!json.isSuccess) throw new Error(json.message); + + setQuestion(prev => prev ? ({ + ...prev, + isNew: json.result?.isNew ?? false, + adminCheckedAt: json.result?.adminCheckedAt ?? prev.adminCheckedAt, + }) : prev); + } catch (err) { + adminCheckRequestedRef.current.delete(requestKey); + console.error('운영진 질문 확인 처리 실패:', err); + } + }, [isStaff, questionId]); + + useEffect(() => { + void markQuestionCheckedByAdmin(); + }, [markQuestionCheckedByAdmin]); + const handleQuestionEvent = useCallback((message) => { if (String(message.data?.questionId) !== String(questionId)) { return; @@ -743,4 +772,4 @@ function QnADetailPage() { ); } -export default QnADetailPage; \ No newline at end of file +export default QnADetailPage; diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index c0f0bf8..a3e389d 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -111,6 +111,27 @@ const updateQuestionGroupsByQuestionEvent = (groups, eventData) => { return hasUpdatedQuestion ? regroupQuestions(updatedQuestions) : groups; }; +const updateQuestionGroupsByCheckedEvent = (groups, eventData) => { + if (!eventData?.questionId) return groups; + + let hasUpdatedQuestion = false; + const updatedQuestions = [ + ...groups.popularQuestions, + ...groups.unresolvedQuestions, + ...groups.resolvedQuestions, + ].map(question => { + if (question.questionId !== eventData.questionId) return question; + + hasUpdatedQuestion = true; + return { + ...question, + isNew: eventData.isNew ?? false, + }; + }); + + return hasUpdatedQuestion ? regroupQuestions(updatedQuestions) : groups; +}; + const addQuestionToGroups = (groups, question) => { if (!question?.questionId) return groups; @@ -315,6 +336,7 @@ function QnAListPage() { isPopular: false, isLiked: false, isMine: false, + isNew: eventData.isNew ?? true, iLiked: false, likeCount: eventData.likeCount ?? 0, commentCount: eventData.commentCount ?? 0, @@ -336,6 +358,11 @@ function QnAListPage() { applyQuestionGroups(nextGroups); }, [applyQuestionGroups]); + const handleQuestionCheckedEvent = useCallback((eventData) => { + const nextGroups = updateQuestionGroupsByCheckedEvent(questionGroupsRef.current, eventData); + applyQuestionGroups(nextGroups); + }, [applyQuestionGroups]); + const handleUnderstandingCheckCreatedEvent = useCallback((eventData) => { if (!eventData?.checkId) return; if (understandingRef.current?.current?.checkId === eventData.checkId) return; @@ -403,6 +430,9 @@ function QnAListPage() { case 'question-updated': handleQuestionUpdatedEvent(data); break; + case 'question-checked': + handleQuestionCheckedEvent(data); + break; case 'understanding-check-created': handleUnderstandingCheckCreatedEvent(data); break; @@ -415,6 +445,7 @@ function QnAListPage() { } }, [ handleCommentCreatedEvent, + handleQuestionCheckedEvent, handleQuestionCreatedEvent, handleQuestionUpdatedEvent, handleUnderstandingCheckCreatedEvent, @@ -782,7 +813,12 @@ function QnAListPage() { className={styles.qIcon} style={{ color: question.isResolved ? 'var(--gray600)' : '' }} >Q. - {question.content} +
+ {question.content} + {isStaff && question.isNew && ( + NEW + )} +