Skip to content
Merged
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
10 changes: 9 additions & 1 deletion docs/api-specs/class-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -851,6 +851,10 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
```

- `roundIndex`에는 화면에 보인 현재 라운드를 보낸다.
- 저장 전에 차단 대상인지 검사한다. 차단되면 저장하지 않고 라운드도 쓰지 않으므로, 고쳐서 같은 라운드로 다시 보내면 된다.
- 개인정보는 서버 규칙으로 검사한다.
- 위협·위협성 혐오·미성년자 성적 표현·자해 방법·폭력적 불법행위는 OpenAI Moderation으로 검사한다. 폭력·범죄를 다루는 정상 논증은 막지 않는다.
- 검사 API에 장애가 나면 막지 않고 통과시킨다.
- `requestKey`는 중복 전송을 막는 키다. 64자 이하이고 UUID를 권장한다.
- 같은 반박을 다시 보낼 때는 같은 키를 쓴다. 같은 키로 다시 보내면 저장된 결과를 그대로 돌려준다.
- 반박을 저장한 뒤 다음 라운드 AI 주장을 생성한다.
Expand All @@ -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` |
Expand Down Expand Up @@ -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 | 클래스 멤버만 볼 수 있습니다. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ public ApiResponse<ClassDebateResponse> 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<ClassDebateResponse> reply(
@AuthenticationPrincipal Long userId,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 "라운드를 소비하지 않는다").
* <ul>
* <li>개인정보: 서버 규칙으로 먼저 본다 (AI 호출 없음)</li>
* <li>위협·혐오: Moderation 범주 중 {@link #BLOCKED_CATEGORIES} 만 막는다. 폭력·범죄·혐오를 다루는 정상 토론은 막지 않는다 (PRD §9)</li>
* <li>Moderation 장애면 토론을 멈추지 않고 통과시킨다. 반박은 AI 프롬프트에서 이미 비신뢰 데이터로 다룬다</li>
* </ul>
* 반박 원문은 로그에 남기지 않는다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ClassDebateInputGuard {

static final Set<String> 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<String> flagged;
try {
flagged = moderationClient.flaggedCategories(content);
} catch (Exception e) {
log.warn("[ClassDebate] moderation unavailable, reply allowed : {}", e.toString());
return;
}
Set<String> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pattern> PATTERNS = List.of(
// 휴대전화 010-1234-5678, 01012345678, 010 1234 5678
Pattern.compile("(?<!\\d)01[016789][-.\\s]?\\d{3,4}[-.\\s]?\\d{4}(?!\\d)"),
// 유선전화 02-123-4567, 031-1234-5678 (구분자 필수)
Pattern.compile("(?<!\\d)0(?:2|[3-6][1-5])[-.)\\s]\\s?\\d{3,4}[-.\\s]\\d{4}(?!\\d)"),
// 이메일
Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"),
// 주민등록·외국인등록번호 900101-1234567 (생년월일 형식 + 성별 자리)
Pattern.compile("(?<!\\d)\\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01])\\s?-?\\s?[1-8]\\d{6}(?!\\d)"),
// 카드번호 1234-5678-9012-3456
Pattern.compile("(?<!\\d)\\d{4}[-\\s]\\d{4}[-\\s]\\d{4}[-\\s]\\d{4}(?!\\d)")
);

private PersonalInfoDetector() {
}

static boolean contains(String text) {
if (text == null || text.isEmpty()) {
return false;
}
return PATTERNS.stream().anyMatch(pattern -> pattern.matcher(text).find());
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<String, Object> 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<Map<String, Object>>() {
});

if (response == null) {
throw new IllegalStateException("empty response from moderation");
}
List<Map<String, Object>> results = ReportContent.mapList(response, "results");
if (results.isEmpty()) {
throw new IllegalStateException("no results in moderation response");
}
Map<String, Object> categories = ReportContent.map(results.get(0), "categories");
Set<String> flagged = new LinkedHashSet<>();
if (categories != null) {
categories.forEach((name, value) -> {
if (Boolean.TRUE.equals(value)) {
flagged.add(name);
}
});
}
return flagged;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 철학자와의 토론을 마친 뒤 사후투표를 할 수 있어요.");

Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -107,6 +110,8 @@ class ClassDebateApiIntegrationTest {

@MockitoBean
private ClassReportAiClient aiClient;
@MockitoBean
private ClassModerationClient moderationClient;

private String ownerToken;
private String memberToken;
Expand All @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading