From 9c1d63ddf51daafd8151db74358a1b2b6eedd4fb Mon Sep 17 00:00:00 2001 From: si-zero Date: Thu, 22 Oct 2026 13:35:57 +0900 Subject: [PATCH] =?UTF-8?q?#563=20[Feat]=20AI=20=EC=B2=A0=ED=95=99?= =?UTF-8?q?=EC=9E=90=20=ED=86=A0=EB=A1=A0=20=EB=B0=98=EB=B0=95=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=B0=A8=EB=8B=A8=20(=EC=9C=84=ED=98=91=C2=B7?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/class-api.md | 10 ++- .../controller/ClassDebateController.java | 4 +- .../service/debate/ClassDebateInputGuard.java | 59 ++++++++++++++++ .../service/debate/ClassDebateService.java | 3 + .../service/debate/PersonalInfoDetector.java | 34 ++++++++++ .../service/report/ClassModerationClient.java | 68 +++++++++++++++++++ .../global/common/exception/ErrorCode.java | 2 + src/main/resources/application.yml | 3 + .../ClassDebateApiIntegrationTest.java | 44 ++++++++++++ .../debate/PersonalInfoDetectorTest.java | 41 +++++++++++ 10 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateInputGuard.java create mode 100644 src/main/java/com/swyp/picke/domain/classroom/service/debate/PersonalInfoDetector.java create mode 100644 src/main/java/com/swyp/picke/domain/classroom/service/report/ClassModerationClient.java create mode 100644 src/test/java/com/swyp/picke/domain/classroom/service/debate/PersonalInfoDetectorTest.java diff --git a/docs/api-specs/class-api.md b/docs/api-specs/class-api.md index fadf43cc..2a29b2a1 100644 --- a/docs/api-specs/class-api.md +++ b/docs/api-specs/class-api.md @@ -411,7 +411,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 |---|---| | 성공 배치 3회 소진 | 409 `CLASS_409_TOPIC_SUGGEST_LIMIT` | | 같은 초안이 이미 생성 중 (3분 지나면 다시 허용) | 409 `CLASS_409_TOPIC_GENERATING` | -| 같은 초안에 분당 2회 초과 | 429 `CLASS_429_TOPIC` | +| 같은 이용권으로 분당 2회 초과 | 429 `CLASS_429_TOPIC` | | 조건 위반 | 400 `CLASS_400_TOPIC_CONDITION` | | 초안 없음 | 404 `CLASS_404_TOPIC_DRAFT` | @@ -851,6 +851,10 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 ``` - `roundIndex`에는 화면에 보인 현재 라운드를 보낸다. +- 저장 전에 차단 대상인지 검사한다. 차단되면 저장하지 않고 라운드도 쓰지 않으므로, 고쳐서 같은 라운드로 다시 보내면 된다. + - 개인정보는 서버 규칙으로 검사한다. + - 위협·위협성 혐오·미성년자 성적 표현·자해 방법·폭력적 불법행위는 OpenAI Moderation으로 검사한다. 폭력·범죄를 다루는 정상 논증은 막지 않는다. + - 검사 API에 장애가 나면 막지 않고 통과시킨다. - `requestKey`는 중복 전송을 막는 키다. 64자 이하이고 UUID를 권장한다. - 같은 반박을 다시 보낼 때는 같은 키를 쓴다. 같은 키로 다시 보내면 저장된 결과를 그대로 돌려준다. - 반박을 저장한 뒤 다음 라운드 AI 주장을 생성한다. @@ -859,6 +863,8 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 | 상황 | 응답 | |---|---| | 반박 길이 위반 | 400 `CLASS_400_DEBATE_REPLY` | +| 개인정보(전화번호·이메일·주민등록번호·카드번호) 포함 | 400 `CLASS_400_DEBATE_PERSONAL_INFO` | +| 위협 등 차단 대상 표현 | 400 `CLASS_400_DEBATE_BLOCKED` | | 다른 키로 같은 라운드를 다시 제출 | 409 `CLASS_409_DEBATE_ROUND` | | 반박을 받을 수 없는 상태 (생성 중·실패·완료) | 409 `CLASS_409_DEBATE_STATE` | | 시작 전 | 404 `CLASS_404_DEBATE` | @@ -1209,6 +1215,8 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 | `CLASS_400_RECOMMEND_SETTINGS` | 400 | 클래스 추천을 켜려면 수준과 카테고리를 먼저 정해 주세요. | | `CLASS_400_TOPIC_CONDITION` | 400 | 주제는 2~200자, 카테고리는 1~3개로 입력해 주세요. | | `CLASS_400_DEBATE_REPLY` | 400 | 반박은 50자 이상 1,000자 이하로 적어 주세요. | +| `CLASS_400_DEBATE_PERSONAL_INFO` | 400 | 전화번호·이메일 같은 개인정보는 지우고 보내 주세요. | +| `CLASS_400_DEBATE_BLOCKED` | 400 | 다른 사람을 위협하거나 해칠 수 있는 표현은 보낼 수 없어요. 내용을 고쳐 주세요. | | `CLASS_403_TICKET` | 403 | 사용 중인 이용권이 없어 클래스를 만들 수 없습니다. | | `CLASS_403_OWNER` | 403 | 클래스 운영자만 할 수 있습니다. | | `CLASS_403_MEMBER` | 403 | 클래스 멤버만 볼 수 있습니다. | diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java index 2dd10dbf..94f2d255 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java @@ -50,7 +50,9 @@ public ApiResponse start( @Operation(summary = "반박 제출", description = "현재 라운드의 반박(50~1,000자)을 저장하고 다음 라운드 AI 주장을 생성합니다. 3라운드 반박이면 토론이 끝나고(COMPLETED) 사후투표를 할 수 있습니다. " + "같은 requestKey 재전송은 저장된 결과를 그대로 돌려주고, 다른 키로 같은 라운드를 다시 내면 409 CLASS_409_DEBATE_ROUND. " - + "반박을 받을 수 없는 상태면 409 CLASS_409_DEBATE_STATE, 길이 위반 400 CLASS_400_DEBATE_REPLY.") + + "반박을 받을 수 없는 상태면 409 CLASS_409_DEBATE_STATE, 길이 위반 400 CLASS_400_DEBATE_REPLY. " + + "개인정보(전화번호·이메일·주민등록번호·카드번호)가 있으면 400 CLASS_400_DEBATE_PERSONAL_INFO, " + + "위협 등 차단 대상 표현이면 400 CLASS_400_DEBATE_BLOCKED (둘 다 저장하지 않고 라운드도 쓰지 않음).") @PostMapping("/replies") public ApiResponse reply( @AuthenticationPrincipal Long userId, diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateInputGuard.java b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateInputGuard.java new file mode 100644 index 00000000..c7560088 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateInputGuard.java @@ -0,0 +1,59 @@ +package com.swyp.picke.domain.classroom.service.debate; + +import com.swyp.picke.domain.classroom.service.report.ClassModerationClient; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 반박을 저장하기 전에 차단 대상인지 본다 (PRD §8 "차단 대상 입력은 저장 단계로 진행하지 않고 수정 안내", §11 "라운드를 소비하지 않는다"). + *
    + *
  • 개인정보: 서버 규칙으로 먼저 본다 (AI 호출 없음)
  • + *
  • 위협·혐오: Moderation 범주 중 {@link #BLOCKED_CATEGORIES} 만 막는다. 폭력·범죄·혐오를 다루는 정상 토론은 막지 않는다 (PRD §9)
  • + *
  • Moderation 장애면 토론을 멈추지 않고 통과시킨다. 반박은 AI 프롬프트에서 이미 비신뢰 데이터로 다룬다
  • + *
+ * 반박 원문은 로그에 남기지 않는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ClassDebateInputGuard { + + static final Set BLOCKED_CATEGORIES = Set.of( + "harassment/threatening", + "hate/threatening", + "sexual/minors", + "self-harm/instructions", + "illicit/violent" + ); + + private final ClassModerationClient moderationClient; + + /** 길이 규칙을 벗어난 반박은 저장 단계에서 400 이 나므로 Moderation 을 부르지 않는다 */ + public void check(String rawContent) { + String content = rawContent == null ? "" : rawContent.strip(); + if (PersonalInfoDetector.contains(content)) { + throw new CustomException(ErrorCode.CLASS_DEBATE_REPLY_PERSONAL_INFO); + } + int length = content.codePointCount(0, content.length()); + if (length < ClassDebateSessionService.REPLY_MIN_LENGTH || length > ClassDebateSessionService.REPLY_MAX_LENGTH) { + return; + } + Set flagged; + try { + flagged = moderationClient.flaggedCategories(content); + } catch (Exception e) { + log.warn("[ClassDebate] moderation unavailable, reply allowed : {}", e.toString()); + return; + } + Set blocked = flagged.stream().filter(BLOCKED_CATEGORIES::contains).collect(Collectors.toSet()); + if (!blocked.isEmpty()) { + log.info("[ClassDebate] reply blocked categories={}", blocked); + throw new CustomException(ErrorCode.CLASS_DEBATE_REPLY_BLOCKED); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateService.java b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateService.java index 6a3306dc..beac1b51 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateService.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/debate/ClassDebateService.java @@ -33,6 +33,7 @@ public class ClassDebateService { private final ClassDebateSessionService sessionService; private final ClassReportAiClient aiClient; private final ClassDebateContentValidator validator; + private final ClassDebateInputGuard inputGuard; /** 재시도 지연의 기준값. 1회차 뒤 base, 2회차 뒤 base×2 에 jitter 를 더한다 */ long retryBackoffMillis = 500; @@ -56,6 +57,8 @@ public ClassDebateResponse start(Long userId, Long classId) { } public ClassDebateResponse reply(Long userId, Long classId, ClassDebateReplyRequest request) { + // 외부 검사는 세션 잠금 밖에서 한다. 차단되면 저장·라운드 소비 없이 끝난다 + inputGuard.check(request.content()); ClassDebateSessionService.PreparedTurn turn = sessionService.reply(userId, classId, request, aiClient.getModel(), now()); if (turn != null) { generate(turn); diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/debate/PersonalInfoDetector.java b/src/main/java/com/swyp/picke/domain/classroom/service/debate/PersonalInfoDetector.java new file mode 100644 index 00000000..f8d634bb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/service/debate/PersonalInfoDetector.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.classroom.service.debate; + +import java.util.List; +import java.util.regex.Pattern; + +/** + * 반박에 섞인 연락처·식별번호를 찾는다 (PRD §11 "위협·개인정보 등 차단 대상"). + * 연도·통계처럼 숫자가 들어간 정상 논증을 막지 않도록 형식이 분명한 번호만 잡는다. + */ +final class PersonalInfoDetector { + + private static final List PATTERNS = List.of( + // 휴대전화 010-1234-5678, 01012345678, 010 1234 5678 + Pattern.compile("(? pattern.matcher(text).find()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/report/ClassModerationClient.java b/src/main/java/com/swyp/picke/domain/classroom/service/report/ClassModerationClient.java new file mode 100644 index 00000000..05dc37ae --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/service/report/ClassModerationClient.java @@ -0,0 +1,68 @@ +package com.swyp.picke.domain.classroom.service.report; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * OpenAI Moderation 호출. 입력이 걸린 범주(categories 중 true) 이름만 돌려주고, 어떤 범주를 막을지는 호출하는 쪽이 정한다. + * Moderation 은 과금되지 않으므로 ai_jobs 에 기록하지 않는다. + */ +@Slf4j +@Component +public class ClassModerationClient { + + private static final int CONNECT_TIMEOUT_MS = 3_000; + private static final int READ_TIMEOUT_MS = 10_000; + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.moderation.url:https://api.openai.com/v1/moderations}") + private String moderationUrl; + + @Value("${openai.moderation.model:omni-moderation-latest}") + private String model; + + /** 걸린 범주 이름 (예: harassment/threatening). 걸린 것이 없으면 빈 집합 */ + public Set flaggedCategories(String text) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT_MS); + factory.setReadTimeout(READ_TIMEOUT_MS); + RestClient restClient = RestClient.builder().requestFactory(factory).build(); + + Map response = restClient.post() + .uri(moderationUrl) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .body(Map.of("model", model, "input", text)) + .retrieve() + .body(new ParameterizedTypeReference>() { + }); + + if (response == null) { + throw new IllegalStateException("empty response from moderation"); + } + List> results = ReportContent.mapList(response, "results"); + if (results.isEmpty()) { + throw new IllegalStateException("no results in moderation response"); + } + Map categories = ReportContent.map(results.get(0), "categories"); + Set flagged = new LinkedHashSet<>(); + if (categories != null) { + categories.forEach((name, value) -> { + if (Boolean.TRUE.equals(value)) { + flagged.add(name); + } + }); + } + return flagged; + } +} diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 572f8259..28789b63 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -197,6 +197,8 @@ public enum ErrorCode { CLASS_DEBATE_NOT_WAITING(HttpStatus.CONFLICT, "CLASS_409_DEBATE_STATE", "지금은 반박을 보낼 수 없어요."), CLASS_DEBATE_ROUND_CONFLICT(HttpStatus.CONFLICT, "CLASS_409_DEBATE_ROUND", "이 라운드의 반박은 이미 제출되었습니다."), CLASS_DEBATE_REPLY_INVALID(HttpStatus.BAD_REQUEST, "CLASS_400_DEBATE_REPLY", "반박은 50자 이상 1,000자 이하로 적어 주세요."), + CLASS_DEBATE_REPLY_PERSONAL_INFO(HttpStatus.BAD_REQUEST, "CLASS_400_DEBATE_PERSONAL_INFO", "전화번호·이메일 같은 개인정보는 지우고 보내 주세요."), + CLASS_DEBATE_REPLY_BLOCKED(HttpStatus.BAD_REQUEST, "CLASS_400_DEBATE_BLOCKED", "다른 사람을 위협하거나 해칠 수 있는 표현은 보낼 수 없어요. 내용을 고쳐 주세요."), CLASS_DEBATE_NOT_RETRYABLE(HttpStatus.CONFLICT, "CLASS_409_DEBATE_RETRY", "지금은 다시 시도할 수 없어요."), CLASS_DEBATE_NOT_COMPLETED(HttpStatus.CONFLICT, "CLASS_409_DEBATE_INCOMPLETE", "AI 철학자와의 토론을 마친 뒤 사후투표를 할 수 있어요."); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 90107c08..5a04fbec 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -88,6 +88,9 @@ openai: api-key: ${OPENAI_API_KEY} url: https://api.openai.com/v1/chat/completions model: gpt-4o-mini + moderation: + url: https://api.openai.com/v1/moderations + model: omni-moderation-latest tts: url: https://api.openai.com/v1/audio/speech model: gpt-4o-mini-tts diff --git a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java index 5f45f1d5..69e7737d 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassDebateApiIntegrationTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -32,6 +33,7 @@ import com.swyp.picke.domain.classroom.repository.ClassroomRepository; import com.swyp.picke.domain.classroom.service.debate.ClassDebatePrompts; import com.swyp.picke.domain.classroom.service.debate.ClassDebateService; +import com.swyp.picke.domain.classroom.service.report.ClassModerationClient; import com.swyp.picke.domain.classroom.service.report.ClassReportAiClient; import com.swyp.picke.domain.classroom.service.topic.ClassTopicPrompts; import com.swyp.picke.domain.oauth.jwt.JwtProvider; @@ -57,6 +59,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -107,6 +110,8 @@ class ClassDebateApiIntegrationTest { @MockitoBean private ClassReportAiClient aiClient; + @MockitoBean + private ClassModerationClient moderationClient; private String ownerToken; private String memberToken; @@ -122,6 +127,7 @@ void setUp() throws Exception { ownerToken = tokenOf(owner); issueAndRegister(owner); when(aiClient.getModel()).thenReturn("test-model"); + when(moderationClient.flaggedCategories(anyString())).thenReturn(Set.of()); when(aiClient.chatJson(eq(ClassTopicPrompts.SUGGEST_SYSTEM), anyString(), anyInt())) .thenReturn(result(candidatesJson())); stubDebate(); @@ -252,6 +258,44 @@ void replyIdempotency() throws Exception { .andExpect(jsonPath("$.error.code").value("CLASS_409_DEBATE_ROUND")); } + @Test + @DisplayName("반박 차단: 개인정보·위협 표현은 저장하지 않고 400, 라운드를 쓰지 않아 고친 반박은 그대로 1라운드로 제출된다") + void replyBlocked() throws Exception { + preVote(optionB); + start(); + + reply(1, longText("제 번호는 010-1234-5678 이에요"), "key-phone") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("CLASS_400_DEBATE_PERSONAL_INFO")); + verify(moderationClient, never()).flaggedCategories(anyString()); + + String threat = longText("위협"); + when(moderationClient.flaggedCategories(threat)).thenReturn(Set.of("harassment", "harassment/threatening")); + reply(1, threat, "key-threat") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("CLASS_400_DEBATE_BLOCKED")); + + // 폭력을 다루는 정상 논증(violence 만 걸림)은 막지 않는다 + String violence = longText("폭력 논증"); + when(moderationClient.flaggedCategories(violence)).thenReturn(Set.of("violence")); + assertThat(messageRepository.findAll()).hasSize(1); + reply(1, violence, "key-violence") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.round").value(2)); + } + + @Test + @DisplayName("Moderation 장애면 반박을 막지 않는다") + void replyAllowedWhenModerationFails() throws Exception { + preVote(optionB); + start(); + when(moderationClient.flaggedCategories(anyString())).thenThrow(new IllegalStateException("down")); + + reply(1, longText("첫 번째 반박"), "key-1") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.round").value(2)); + } + @Test @DisplayName("AI 장애: 3회 실패하면 FAILED(반박 불가·재시도 가능), 재시도는 같은 작업으로 성공") void failureAndRetry() throws Exception { diff --git a/src/test/java/com/swyp/picke/domain/classroom/service/debate/PersonalInfoDetectorTest.java b/src/test/java/com/swyp/picke/domain/classroom/service/debate/PersonalInfoDetectorTest.java new file mode 100644 index 00000000..ce7e1aea --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/classroom/service/debate/PersonalInfoDetectorTest.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.classroom.service.debate; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PersonalInfoDetectorTest { + + @ParameterizedTest + @DisplayName("연락처·식별번호가 있으면 잡는다") + @ValueSource(strings = { + "연락은 010-1234-5678 로 주세요", + "01012345678 번호로", + "010 1234 5678", + "사무실 02-123-4567 입니다", + "031-1234-5678", + "메일 test.user@example.com 으로", + "제 주민번호는 900101-1234567", + "9001011234567", + "카드 1234-5678-9012-3456" + }) + void detects(String text) { + assertThat(PersonalInfoDetector.contains(text)).isTrue(); + } + + @ParameterizedTest + @DisplayName("연도·통계·일반 숫자가 든 정상 논증은 잡지 않는다") + @ValueSource(strings = { + "2024년 조사에서 1,000명 중 30%가 그렇게 답했어요", + "1789년 프랑스 혁명 이후 인권 개념이 넓어졌어요", + "10대 청소년 2명 중 1명은 처벌보다 교육이 낫다고 봤어요", + "버전 1.2.3 처럼 숫자가 섞인 문장", + "인구 51,000,000명의 나라에서", + "123456 같은 여섯 자리 숫자" + }) + void ignoresNormalNumbers(String text) { + assertThat(PersonalInfoDetector.contains(text)).isFalse(); + } +}