diff --git a/CLAUDE.md b/CLAUDE.md index a2b0545..09587e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,7 +192,7 @@ domain/{도메인}/ **AI 톤 교정** - 모든 사용자 하루 5회 (KST 날짜별 1행) - 요청 시 `used_count < 한도` 조건부 증가로 1회를 먼저 잡고, 외부 AI 오류·20초 타임아웃·혐오 표현·욕설 차단이면 되돌린다. -- 출력 토큰 상한 4000을 반드시 설정한다. +- 출력 토큰 상한 6000을 반드시 설정한다 (생각 토큰 포함. 3000자 원문 보고/공문 교정이 4000에서는 잘렸다). - **원문과 결과는 저장하지도, 로그에 남기지도 않는다.** 예외 로그에도 넣지 않는다. **인증** diff --git a/docs/s1-api-spec.md b/docs/s1-api-spec.md index 50577ea..97b06e5 100644 --- a/docs/s1-api-spec.md +++ b/docs/s1-api-spec.md @@ -4,6 +4,8 @@ | --- | --- | | 버전 | v0.8 (2026-11-01, PRO 결제 기능 제거·정렬 고정 반영) | | v0.7 대비 변경 | 플랜 구분 삭제. 템플릿 한도는 모든 사용자 50개, AI 톤 교정은 모든 사용자 하루 5회 (기능명세서 COM-03·05). 6번 사용량 응답에서 `plan`·`aiTone.available` 삭제, 22번 `AI403_1` 삭제, 공통 규칙 3·6·7·9장 정리. **내 템플릿 정렬을 생성 순(오래된 것 먼저)으로 고정**(기능명세서 MYT-03 삭제): 7번 정렬 저장 API 삭제, 5번 `templateSort`·8번 `sort` 파라미터와 응답 필드·`TEMPLATE400_8` 삭제. API는 21개 (번호는 그대로 두고 7번을 비움) | +| v0.8 수정 (2026-11-13) | 22번 비고: 서버 내부 오류(500)도 횟수를 차감하지 않음 (선점한 1회를 되돌림, PR #86 리뷰) | +| v0.8 수정 (2026-11-10) | 22번 비고: 출력 토큰 상한 4,000 → 6,000 (#64 실제 모델 확인에서 3000자 원문 보고/공문 교정이 4,000에 잘림), 처리 중 이탈은 감지하지 않아 차감됨으로 변경 | | v0.8 수정 (2026-11-10) | AI 톤 교정: 감성/에세이 문체(`ESSAY`) 삭제, 문체 6개 (공통 규칙 6장, 22번 `style`). 욕설·비속어도 혐오 표현과 같이 차단하고 22번 `AI422_1` 문구·설명 변경 | | v0.6 → v0.7 | 모든 시각에 `+09:00` 시간대 표시 추가(공통 규칙 4장), 패키지 컨벤션 확정(10장) | | 기본 경로 | `/api/v1` | @@ -2391,9 +2393,9 @@ POST /api/v1/ai/tone-corrections - **핵심 정보 검사**: 원문과 결과에서 날짜·시간·금액·단위가 붙은 숫자를 뽑아 같은 형태로 바꾼 뒤 비교합니다. 빠지거나 새로 생긴 값이 있으면 `keyInfoWarning`을 채웁니다. 고유명사는 검사하지 않습니다. - 경고가 있어도 **결과는 정상 응답**이고 횟수도 차감됩니다. 화면은 결과 아래에 `keyInfoWarning.message`를 표시합니다(기획 확정 통합 문구). -- **횟수 처리**: 요청 시 1회를 먼저 잡아두고(남은 횟수 없으면 AI429_1), AI 오류·타임아웃·혐오 표현·욕설 차단이면 되돌립니다. -- **처리 중 이탈**: 서버가 연결 끊김을 감지하면 되돌리지만, 이미 결과가 만들어진 뒤 이탈하면 차감될 수 있습니다. -- 외부 AI 대기 시간은 20초. 넘으면 AI504_1. 출력 토큰 상한(4,000)을 반드시 설정합니다(비용 상한). +- **횟수 처리**: 요청 시 1회를 먼저 잡아두고(남은 횟수 없으면 AI429_1), AI 오류·타임아웃·혐오 표현·욕설 차단이면 되돌립니다. 서버 내부 오류(500)도 횟수를 차감하지 않습니다. +- **처리 중 이탈**: 지금은 서버가 연결 끊김을 감지하지 않아, 처리 중 화면을 떠나도 횟수가 차감됩니다. +- 외부 AI 대기 시간은 20초. 넘으면 AI504_1. 출력 토큰 상한(6,000)을 반드시 설정합니다(비용 상한). - 원문과 결과는 저장하지도, 로그에 남기지도 않습니다 (정책표 8). 에러 로그에도 원문 제외. - "이전 버전으로 돌아가기"(AIT-04)용 이력은 화면에서만 보관합니다. - 모든 사용자가 쓸 수 있습니다. v0.7의 `AI403_1`(Pro 전용)은 삭제했고, 번호는 다시 쓰지 않습니다. diff --git a/src/main/java/com/sniptext/domain/ai/controller/AiSuccessCode.java b/src/main/java/com/sniptext/domain/ai/controller/AiSuccessCode.java new file mode 100644 index 0000000..0285db9 --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/controller/AiSuccessCode.java @@ -0,0 +1,20 @@ +package com.sniptext.domain.ai.controller; + +import org.springframework.http.HttpStatus; + +import com.sniptext.global.response.ResultCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AiSuccessCode implements ResultCode { + + TONE_CORRECTED(HttpStatus.OK, "AI200_1", "톤 교정에 성공했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/sniptext/domain/ai/controller/AiToneCorrectionController.java b/src/main/java/com/sniptext/domain/ai/controller/AiToneCorrectionController.java new file mode 100644 index 0000000..1b73399 --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/controller/AiToneCorrectionController.java @@ -0,0 +1,39 @@ +package com.sniptext.domain.ai.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sniptext.domain.ai.controller.dto.AiToneCorrectRequest; +import com.sniptext.domain.ai.controller.dto.AiToneCorrectResponse; +import com.sniptext.domain.ai.service.AiToneCorrectionCommandService; +import com.sniptext.domain.ai.service.dto.result.AiToneCorrectResult; +import com.sniptext.global.response.ApiResponse; +import com.sniptext.global.security.LoginUser; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +/** + * AI 톤 교정 (로그인 필요, 캐시 금지). + */ +@RestController +@RequestMapping("/api/v1/ai/tone-corrections") +@RequiredArgsConstructor +public class AiToneCorrectionController { + + private final AiToneCorrectionCommandService aiToneCorrectionCommandService; + + @PostMapping + public ResponseEntity> correct( + @LoginUser Long userId, + @Valid @RequestBody AiToneCorrectRequest request) { + + AiToneCorrectResult result = aiToneCorrectionCommandService.correct(request.toCommand(userId)); + + return ApiResponse.success(AiSuccessCode.TONE_CORRECTED, AiToneCorrectResponse.from(result)); + } + +} diff --git a/src/main/java/com/sniptext/domain/ai/controller/dto/AiToneCorrectRequest.java b/src/main/java/com/sniptext/domain/ai/controller/dto/AiToneCorrectRequest.java new file mode 100644 index 0000000..53e82c0 --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/controller/dto/AiToneCorrectRequest.java @@ -0,0 +1,27 @@ +package com.sniptext.domain.ai.controller.dto; + +import com.sniptext.domain.ai.annotation.ValidToneStyle; +import com.sniptext.domain.ai.annotation.ValidToneText; +import com.sniptext.domain.ai.entity.ToneStyle; +import com.sniptext.domain.ai.service.dto.command.AiToneCorrectCommand; + +/** + * AI 톤 교정 (명세 22번). 여러 개가 틀리면 선언 순서상 첫 에러 하나만 내려주므로 style → text 순서를 지킨다. + * style을 ToneStyle로 받으면 없는 값이 JSON 변환 실패(COMMON400_1)가 되므로 문자열로 받는다. + * 원문은 저장·로그 금지 대상이라 toString에는 길이만 보인다 (디버그 로그가 요청 객체를 toString으로 찍는다). + */ +public record AiToneCorrectRequest( + @ValidToneStyle String style, + @ValidToneText String text +) { + + public AiToneCorrectCommand toCommand(Long userId) { + return new AiToneCorrectCommand(userId, ToneStyle.valueOf(style), text); + } + + @Override + public String toString() { + return "AiToneCorrectRequest[style=%s, textLength=%s]".formatted(style, text == null ? null : text.length()); + } + +} diff --git a/src/main/java/com/sniptext/domain/ai/controller/dto/AiToneCorrectResponse.java b/src/main/java/com/sniptext/domain/ai/controller/dto/AiToneCorrectResponse.java new file mode 100644 index 0000000..ce2495c --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/controller/dto/AiToneCorrectResponse.java @@ -0,0 +1,62 @@ +package com.sniptext.domain.ai.controller.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.sniptext.domain.ai.entity.ToneStyle; +import com.sniptext.domain.ai.service.dto.result.AiToneCorrectResult; +import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult; +import com.sniptext.domain.ai.support.KeyInfoDiff; + +/** + * AI 톤 교정 결과 (명세 22번). 핵심 정보 차이가 없으면 keyInfoWarning은 null이다. + * 교정 결과는 저장·로그 금지 대상이라 toString에는 길이만 보인다. + */ +public record AiToneCorrectResponse( + String correctedText, + ToneStyle style, + KeyInfoWarning keyInfoWarning, + Usage usage +) { + + /** 기획 확정 통합 문구. 화면이 그대로 띄운다 */ + static final String KEY_INFO_WARNING_MESSAGE = "원문의 일부 정보가 빠지거나 변경되었을 수 있어요. 확인 후 사용해주세요."; + + public static AiToneCorrectResponse from(AiToneCorrectResult result) { + return new AiToneCorrectResponse(result.correctedText(), result.style(), + KeyInfoWarning.from(result.keyInfoDiff()), Usage.from(result.usage())); + } + + @Override + public String toString() { + return "AiToneCorrectResponse[correctedTextLength=%d, style=%s, keyInfoWarning=%s, usage=%s]".formatted( + correctedText.length(), style, keyInfoWarning, usage); + } + + public record KeyInfoWarning(String message, List missingValues, List addedValues) { + + static KeyInfoWarning from(KeyInfoDiff diff) { + if (!diff.hasDifference()) { + return null; + } + return new KeyInfoWarning(KEY_INFO_WARNING_MESSAGE, diff.missingValues(), diff.addedValues()); + } + + /** 값은 원문·결과 조각이라 개수만 보인다. */ + @Override + public String toString() { + return "KeyInfoWarning[missing=%d, added=%d]".formatted(missingValues.size(), addedValues.size()); + } + + } + + /** 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다. */ + public record Usage(int usedCount, int limit, int remaining, LocalDateTime resetAt) { + + static Usage from(AiToneUsageResult usage) { + return new Usage(usage.usedCount(), usage.limit(), usage.remaining(), usage.resetAt()); + } + + } + +} diff --git a/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java b/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java index da16935..0dcbe09 100644 --- a/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java +++ b/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java @@ -22,13 +22,16 @@ import lombok.extern.slf4j.Slf4j; /** - * AI 톤 교정 (명세 22번). 1회를 먼저 잡고 언어 모델을 부른 뒤, 차단·외부 오류·시간 초과면 되돌린다. + * AI 톤 교정 (명세 22번). 1회를 먼저 잡고 언어 모델을 부른 뒤, 정상 결과를 돌려주지 못하면 되돌린다. + * 차단·외부 오류·시간 초과(BusinessException)와 서버 내부 오류(그 밖의 RuntimeException, 500) 모두 되돌린다. + * 반환은 {@link #correct}의 catch 한 곳에서만 하므로 한 요청에서 두 번 반환되지 않는다. *

* 클래스·메서드 모두 트랜잭션을 걸지 않는다 ("명령 서비스는 쓰기 메서드마다 @Transactional" 규칙의 예외). * 외부 호출(최대 20초) 동안 DB 커넥션을 쥐지 않도록, 선점·반환이 각자 짧은 트랜잭션으로 끝난다 * (AiToneUsageCommandService는 호출자 트랜잭션 안에서 부르면 예외를 던진다). *

* 원문과 결과는 저장하지도 로그에 남기지도 않는다. 로그에는 회원 번호·문체·결과 종류·소요 시간·출력 토큰 수만 남긴다. + * 서버 내부 오류는 메시지에 원문이 섞일 수 있어 예외 클래스 이름만 남긴다. */ @Slf4j @Service @@ -46,47 +49,58 @@ public AiToneCorrectResult correct(AiToneCorrectCommand command) { userQueryService.validateActive(command.userId()); AiToneReserveResult reservation = aiToneUsageCommandService.reserve(command.userId()); - LanguageModelResult modelResult = complete(command, reservation); - - String correctedText = TextWhitespace.strip(modelResult.text()); - return new AiToneCorrectResult(correctedText, command.style(), - KeyInfoChecker.compare(command.text(), correctedText), usage(reservation)); + long startedAt = System.nanoTime(); + try { + LanguageModelResult modelResult = complete(command, startedAt); + String correctedText = TextWhitespace.strip(modelResult.text()); + return new AiToneCorrectResult(correctedText, command.style(), + KeyInfoChecker.compare(command.text(), correctedText), usage(reservation)); + } catch (RuntimeException e) { + // 선점 뒤 실패는 모두 여기서 한 번만 반환한다. 업무 에러는 complete()에서 이미 로그를 남겼다 + if (!(e instanceof BusinessException)) { + log.error("AI 톤 교정 서버 오류 userId={} style={} elapsedMs={} exception={}", command.userId(), + command.style(), elapsedMillis(startedAt), e.getClass().getName()); + } + release(reservation); + throw e; + } } - /** 언어 모델을 부른다. 차단·외부 오류·시간 초과면 선점한 1회를 되돌리고 에러를 던진다. 반환은 이 세 경우에서만 한다. */ - private LanguageModelResult complete(AiToneCorrectCommand command, AiToneReserveResult reservation) { + /** + * 언어 모델을 부른다. 차단·외부 오류·시간 초과면 해당 에러 코드의 BusinessException을 던진다. + * 반환은 하지 않는다 (호출한 {@link #correct}가 이 에러와 서버 내부 오류 모두에서 반환한다). + */ + private LanguageModelResult complete(AiToneCorrectCommand command, long startedAt) { LanguageModelRequest request = new LanguageModelRequest(ToneCorrectionPrompt.systemPrompt(command.style()), ToneCorrectionPrompt.userMessage(command.text()), MAX_OUTPUT_TOKENS); - long startedAt = System.nanoTime(); LanguageModelResult result; try { result = languageModelClient.complete(request); } catch (LanguageModelTimeoutException e) { logFailure(command, startedAt, e); - throw releaseAndFail(reservation, AiErrorCode.LANGUAGE_MODEL_TIMEOUT); + throw new BusinessException(AiErrorCode.LANGUAGE_MODEL_TIMEOUT); } catch (LanguageModelException e) { logFailure(command, startedAt, e); - throw releaseAndFail(reservation, AiErrorCode.LANGUAGE_MODEL_FAILED); + throw new BusinessException(AiErrorCode.LANGUAGE_MODEL_FAILED); } boolean blocked = result.refused() || ToneCorrectionPrompt.isBlocked(result.text()); log.info("AI 톤 교정 userId={} style={} outcome={} elapsedMs={} outputTokens={}", command.userId(), command.style(), blocked ? "blocked" : "success", elapsedMillis(startedAt), result.outputTokens()); if (blocked) { - throw releaseAndFail(reservation, AiErrorCode.INAPPROPRIATE_TEXT); + throw new BusinessException(AiErrorCode.INAPPROPRIATE_TEXT); } return result; } - /** 반환이 실패해도 원래 에러를 그대로 돌려준다. 로그에는 회원 번호·날짜와 예외 종류만 남긴다. */ - private BusinessException releaseAndFail(AiToneReserveResult reservation, AiErrorCode errorCode) { + /** 반환이 실패해도 호출자가 원래 예외를 그대로 던진다. 로그에는 회원 번호·날짜와 예외 종류만 남긴다. */ + private void release(AiToneReserveResult reservation) { try { aiToneUsageCommandService.release(reservation); } catch (RuntimeException e) { log.error("AI 톤 교정 사용량 반환 실패 userId={} usageDate={} exception={}", reservation.userId(), reservation.usageDate(), e.getClass().getSimpleName()); } - return new BusinessException(errorCode); } // 언어 모델 예외 메시지는 상태 코드·오류 종류만 담는다 (원문·응답 본문 없음) diff --git a/src/test/java/com/sniptext/domain/ai/controller/AiToneCorrectionControllerTest.java b/src/test/java/com/sniptext/domain/ai/controller/AiToneCorrectionControllerTest.java new file mode 100644 index 0000000..6bba51e --- /dev/null +++ b/src/test/java/com/sniptext/domain/ai/controller/AiToneCorrectionControllerTest.java @@ -0,0 +1,283 @@ +package com.sniptext.domain.ai.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; + +import com.sniptext.domain.ai.repository.AiToneDailyUsageRepository; +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.fixture.UserFixture; +import com.sniptext.global.client.llm.LanguageModelClient; +import com.sniptext.global.client.llm.LanguageModelException; +import com.sniptext.global.client.llm.LanguageModelResult; +import com.sniptext.global.client.llm.LanguageModelTimeoutException; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +import tools.jackson.databind.json.JsonMapper; + +/** + * 명세 22번. 실제 필터·MySQL을 거치고, 언어 모델은 가짜 구현을 감싼 스파이로 실패를 만든다. + * 클래스에 @Transactional을 붙이지 않는다. 서비스가 트랜잭션 밖에서 선점·반환을 부르는지까지 확인해야 한다 + * (선점·반환은 호출자 트랜잭션 안에서 부르면 예외). 대신 끝나면 만든 사용량과 회원을 지운다. + */ +@Import(MutableClockConfiguration.class) +@ExtendWith(OutputCaptureExtension.class) +class AiToneCorrectionControllerTest extends IntegrationTestSupport { + + private static final String PATH = "/api/v1/ai/tone-corrections"; + private static final LocalDateTime NOW = LocalDateTime.of(2026, 10, 11, 23, 59, 30); + private static final LocalDate TODAY = NOW.toLocalDate(); + /** 로그 검사용으로 다른 곳에 나오지 않을 문장을 쓴다 */ + private static final String TEXT = "원문비밀문장 11월 30일까지 외주 못 받는다고 해줘"; + private static final String CORRECTED = "결과비밀문장 외주를 받기 어렵습니다."; + private static final List FIELD_ORDER = List.of("correctedText", "style", "keyInfoWarning", "usage"); + + @MockitoSpyBean + private LanguageModelClient languageModelClient; + + @Autowired + private AiToneDailyUsageRepository aiToneDailyUsageRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private MutableClock clock; + + @Autowired + private JsonMapper jsonMapper; + + private Long userId; + + @BeforeEach + void setUp() { + clock.setTo(NOW); + userId = userRepository.save(UserFixture.user("tone@sniptext.com")).getId(); + } + + @AfterEach + void tearDown() { + aiToneDailyUsageRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("성공하면 200 AI200_1, 명세 순서의 필드, 선점 후 사용량과 다음 날 00:00+09:00 리셋 시각, 캐시 금지") + void success() throws Exception { + MvcResult result = correct(userId, "BOSS_EXTERNAL", TEXT) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("AI200_1")) + .andExpect(jsonPath("$.message").value("톤 교정에 성공했습니다.")) + .andExpect(jsonPath("$.result.correctedText").value("[가짜 교정] " + TEXT)) + .andExpect(jsonPath("$.result.style").value("BOSS_EXTERNAL")) + .andExpect(jsonPath("$.result.keyInfoWarning").isEmpty()) + .andExpect(jsonPath("$.result.usage.usedCount").value(1)) + .andExpect(jsonPath("$.result.usage.limit").value(5)) + .andExpect(jsonPath("$.result.usage.remaining").value(4)) + .andExpect(jsonPath("$.result.usage.resetAt").value("2026-10-12T00:00:00+09:00")) + .andReturn(); + + assertFieldOrder(body(result)); + assertThat(usedCount()).isEqualTo(1); + } + + @Test + @DisplayName("날짜가 빠지거나 바뀌면 정상 응답에 명세 문구의 keyInfoWarning이 채워지고 횟수는 차감된 채다") + void keyInfoWarning() throws Exception { + doReturn(new LanguageModelResult("12월 1일까지 " + CORRECTED, false, 50)).when(languageModelClient).complete(any()); + + correct(userId, "REPORT_OFFICIAL", TEXT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("AI200_1")) + .andExpect(jsonPath("$.result.keyInfoWarning.message") + .value("원문의 일부 정보가 빠지거나 변경되었을 수 있어요. 확인 후 사용해주세요.")) + .andExpect(jsonPath("$.result.keyInfoWarning.missingValues[0]").value("11월 30일")) + .andExpect(jsonPath("$.result.keyInfoWarning.addedValues[0]").value("12월 1일")); + + assertThat(usedCount()).isEqualTo(1); + } + + @Test + @DisplayName("5회 사용 후 6번째는 AI429_1이고 언어 모델을 부르지 않는다") + void sixthRequest() throws Exception { + for (int i = 1; i <= 5; i++) { + correct(userId, "CASUAL", TEXT).andExpect(jsonPath("$.result.usage.remaining").value(5 - i)); + } + clearInvocations(languageModelClient); + + expectError(correct(userId, "CASUAL", TEXT), 429, "AI429_1", "오늘 톤 교정 횟수를 모두 사용했어요."); + + verify(languageModelClient, never()).complete(any()); + assertThat(usedCount()).isEqualTo(5); + } + + @Test + @DisplayName("거절·표식이면 AI422_1, 외부 오류면 AI502_1, 시간 초과면 AI504_1이고 사용량은 요청 전 값으로 돌아간다") + void failuresRelease() throws Exception { + correct(userId, "CASUAL", TEXT).andExpect(status().isOk()); + + doReturn(new LanguageModelResult("", true, 10)).when(languageModelClient).complete(any()); + expectError(correct(userId, "CASUAL", TEXT), 422, "AI422_1", "부적절한 표현이 있어 교정할 수 없어요. 수정 후 다시 시도해 주세요."); + assertThat(usedCount()).isEqualTo(1); + + doReturn(new LanguageModelResult("\n[[SNIPTEXT_BLOCKED]]\n", false, 21)).when(languageModelClient).complete(any()); + expectError(correct(userId, "CASUAL", TEXT), 422, "AI422_1", "부적절한 표현이 있어 교정할 수 없어요. 수정 후 다시 시도해 주세요."); + assertThat(usedCount()).isEqualTo(1); + + doThrow(new LanguageModelException("언어 모델 출력 토큰 상한 도달")).when(languageModelClient).complete(any()); + expectError(correct(userId, "CASUAL", TEXT), 502, "AI502_1", "일시적인 오류로 요청을 처리하지 못했어요. 다시 한 번 물어봐 주시겠어요?"); + assertThat(usedCount()).isEqualTo(1); + + doThrow(new LanguageModelTimeoutException("언어 모델 응답 시간 초과")).when(languageModelClient).complete(any()); + expectError(correct(userId, "CASUAL", TEXT), 504, "AI504_1", "일시적인 오류로 요청을 처리하지 못했어요. 다시 한 번 물어봐 주시겠어요?"); + assertThat(usedCount()).isEqualTo(1); + } + + @ParameterizedTest + @ValueSource(strings = {"", "ESSAY", "casual", "UNKNOWN"}) + @DisplayName("style이 비었거나 없는 값·소문자면 AI400_1 (COMMON400_1이 아님)이고 사용량 행이 생기지 않는다") + void invalidStyle(String style) throws Exception { + expectError(correct(userId, style, TEXT), 400, "AI400_1", "문체를 선택해주세요."); + assertThat(aiToneDailyUsageRepository.count()).isZero(); + } + + @Test + @DisplayName("style·text가 없으면 style 오류(AI400_1)가 먼저이고, text만 없거나 공백만이면 AI400_2") + void missingFields() throws Exception { + expectError(request(Map.of()), 400, "AI400_1", "문체를 선택해주세요."); + expectError(request(Map.of("style", "CASUAL")), 400, "AI400_2", "교정할 문장을 입력해주세요."); + expectError(correct(userId, "CASUAL", "   \n"), 400, "AI400_2", "교정할 문장을 입력해주세요."); + assertThat(aiToneDailyUsageRepository.count()).isZero(); + } + + @Test + @DisplayName("text는 3000자까지 통과하고 3001자면 AI400_3") + void textLength() throws Exception { + correct(userId, "CASUAL", "가".repeat(3000)).andExpect(status().isOk()); + expectError(correct(userId, "CASUAL", "가".repeat(3001)), 400, "AI400_3", "문장은 3,000자까지 입력할 수 있어요."); + assertThat(usedCount()).isEqualTo(1); + } + + @Test + @DisplayName("없는 회원의 토큰이면 USER404_1이고 사용량 행이 생기지 않는다 (500이 아님)") + void missingUser() throws Exception { + expectError(correct(-1L, "CASUAL", TEXT), 404, "USER404_1", "회원 정보를 찾을 수 없어요."); + assertThat(aiToneDailyUsageRepository.count()).isZero(); + } + + @Test + @DisplayName("토큰 없이 요청하면 401 COMMON401_1") + void unauthenticated() throws Exception { + mockMvc.perform(post(PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonMapper.writeValueAsString(Map.of("style", "CASUAL", "text", TEXT)))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("COMMON401_1")); + } + + @Test + @DisplayName("성공·422·502·504·예상 못 한 500 모두 로그에 원문·결과가 없고, 소요 시간·출력 토큰 수는 남는다") + void logsHideText(CapturedOutput output) throws Exception { + doReturn(new LanguageModelResult(CORRECTED, false, 4321)).when(languageModelClient).complete(any()); + correct(userId, "CASUAL", TEXT).andExpect(status().isOk()); + + doReturn(new LanguageModelResult("[[SNIPTEXT_BLOCKED]]", false, 21)).when(languageModelClient).complete(any()); + correct(userId, "CASUAL", TEXT).andExpect(status().is(422)); + + doThrow(new LanguageModelException("언어 모델 HTTP 오류: 529")).when(languageModelClient).complete(any()); + correct(userId, "CASUAL", TEXT).andExpect(status().is(502)); + + doThrow(new LanguageModelTimeoutException("언어 모델 응답 시간 초과")).when(languageModelClient).complete(any()); + correct(userId, "CASUAL", TEXT).andExpect(status().is(504)); + + doThrow(new IllegalStateException("예상 못 한 오류")).when(languageModelClient).complete(any()); + correct(userId, "CASUAL", TEXT).andExpect(status().is(500)); + + assertThat(output.getAll()) + .doesNotContain("원문비밀문장", "결과비밀문장") + .contains("outcome=success", "outputTokens=4321", "outcome=blocked", "elapsedMs="); + } + + private ResultActions correct(Long requestUserId, String style, String text) throws Exception { + Map body = new LinkedHashMap<>(); + body.put("style", style); + body.put("text", text); + return request(requestUserId, body); + } + + private ResultActions request(Map body) throws Exception { + return request(userId, body); + } + + private ResultActions request(Long requestUserId, Map body) throws Exception { + return mockMvc.perform(post(PATH) + .header(HttpHeaders.AUTHORIZATION, bearer(requestUserId)) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonMapper.writeValueAsString(body))); + } + + private static void expectError(ResultActions actions, int status, String code, String message) throws Exception { + actions.andExpect(status().is(status)) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value(code)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.result").isEmpty()); + } + + private int usedCount() { + return aiToneDailyUsageRepository.findUsedCount(userId, TODAY).orElse(0); + } + + private static String body(MvcResult result) throws Exception { + return result.getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + private static void assertFieldOrder(String json) { + List positions = new ArrayList<>(); + for (String field : FIELD_ORDER) { + int position = json.indexOf("\"" + field + "\""); + assertThat(position).as(field).isNotNegative(); + positions.add(position); + } + assertThat(positions).isSorted(); + } + +} diff --git a/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java b/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java index 40a29e3..b259548 100644 --- a/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java +++ b/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java @@ -6,7 +6,9 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -23,6 +25,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import com.sniptext.domain.ai.entity.ToneStyle; @@ -31,6 +34,7 @@ import com.sniptext.domain.ai.service.dto.result.AiToneCorrectResult; import com.sniptext.domain.ai.service.dto.result.AiToneReserveResult; import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult; +import com.sniptext.domain.ai.support.KeyInfoChecker; import com.sniptext.domain.user.exception.UserErrorCode; import com.sniptext.domain.user.service.UserQueryService; import com.sniptext.global.client.llm.LanguageModelClient; @@ -138,7 +142,7 @@ void refused() { assertErrorCode("AI422_1"); - verify(aiToneUsageCommandService).release(RESERVATION); + verify(aiToneUsageCommandService, times(1)).release(RESERVATION); } @ParameterizedTest @@ -149,7 +153,7 @@ void blockedMarker(String modelText) { assertErrorCode("AI422_1"); - verify(aiToneUsageCommandService).release(RESERVATION); + verify(aiToneUsageCommandService, times(1)).release(RESERVATION); } @Test @@ -159,7 +163,7 @@ void languageModelFailed() { assertErrorCode("AI502_1"); - verify(aiToneUsageCommandService).release(RESERVATION); + verify(aiToneUsageCommandService, times(1)).release(RESERVATION); } @Test @@ -169,26 +173,56 @@ void languageModelTimeout() { assertErrorCode("AI504_1"); - verify(aiToneUsageCommandService).release(RESERVATION); + verify(aiToneUsageCommandService, times(1)).release(RESERVATION); } @Test - @DisplayName("반환이 실패해도 원래 에러 코드를 그대로 던진다") + @DisplayName("반환이 실패해도 원래 에러 코드를 그대로 던지고, 반환을 다시 시도하지 않는다") void releaseFails() { given(languageModelClient.complete(any())).willThrow(new LanguageModelTimeoutException("언어 모델 응답 시간 초과")); willThrow(new IllegalStateException("DB 오류")).given(aiToneUsageCommandService).release(RESERVATION); assertErrorCode("AI504_1"); + + verify(aiToneUsageCommandService, times(1)).release(RESERVATION); } @Test - @DisplayName("그 밖의 예상 못 한 예외는 그대로 올라가고 반환하지 않는다") - void unexpectedException() { - given(languageModelClient.complete(any())).willThrow(new IllegalStateException("예상 못 한 오류")); + @DisplayName("언어 모델 호출에서 예상 못 한 런타임 예외가 나면 선점한 1회를 한 번 반환하고 원래 예외를 그대로 던진다") + void unexpectedExceptionFromModelClient() { + IllegalStateException unexpected = new IllegalStateException("예상 못 한 오류"); + given(languageModelClient.complete(any())).willThrow(unexpected); - assertThatThrownBy(() -> service.correct(COMMAND)).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> service.correct(COMMAND)).isSameAs(unexpected); - verify(aiToneUsageCommandService, never()).release(any()); + verify(aiToneUsageCommandService, times(1)).release(RESERVATION); + } + + @Test + @DisplayName("결과 조립(핵심 정보 검사)에서 예외가 나면 선점한 1회를 한 번 반환하고 원래 예외를 그대로 던진다") + void unexpectedExceptionWhileAssemblingResult() { + givenModelReturns("안녕하세요. 10월 14일까지 300만 원으로 진행 가능합니다.", false); + IllegalArgumentException unexpected = new IllegalArgumentException("비교 실패"); + + try (MockedStatic checker = mockStatic(KeyInfoChecker.class)) { + checker.when(() -> KeyInfoChecker.compare(any(), any())).thenThrow(unexpected); + + assertThatThrownBy(() -> service.correct(COMMAND)).isSameAs(unexpected); + } + + verify(aiToneUsageCommandService, times(1)).release(RESERVATION); + } + + @Test + @DisplayName("서버 내부 오류 뒤 반환이 실패해도 원래 예외를 그대로 던진다") + void releaseFailsAfterUnexpectedException() { + IllegalStateException unexpected = new IllegalStateException("예상 못 한 오류"); + given(languageModelClient.complete(any())).willThrow(unexpected); + willThrow(new IllegalStateException("DB 오류")).given(aiToneUsageCommandService).release(RESERVATION); + + assertThatThrownBy(() -> service.correct(COMMAND)).isSameAs(unexpected); + + verify(aiToneUsageCommandService, times(1)).release(RESERVATION); } @Test