diff --git a/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java b/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java new file mode 100644 index 0000000..da16935 --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java @@ -0,0 +1,110 @@ +package com.sniptext.domain.ai.service; + +import org.springframework.stereotype.Service; + +import com.sniptext.domain.ai.exception.AiErrorCode; +import com.sniptext.domain.ai.service.dto.command.AiToneCorrectCommand; +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.service.UserQueryService; +import com.sniptext.global.client.llm.LanguageModelClient; +import com.sniptext.global.client.llm.LanguageModelException; +import com.sniptext.global.client.llm.LanguageModelRequest; +import com.sniptext.global.client.llm.LanguageModelResult; +import com.sniptext.global.client.llm.LanguageModelTimeoutException; +import com.sniptext.global.exception.BusinessException; +import com.sniptext.global.policy.UsageLimits; +import com.sniptext.global.text.TextWhitespace; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * AI 톤 교정 (명세 22번). 1회를 먼저 잡고 언어 모델을 부른 뒤, 차단·외부 오류·시간 초과면 되돌린다. + *
+ * 클래스·메서드 모두 트랜잭션을 걸지 않는다 ("명령 서비스는 쓰기 메서드마다 @Transactional" 규칙의 예외). + * 외부 호출(최대 20초) 동안 DB 커넥션을 쥐지 않도록, 선점·반환이 각자 짧은 트랜잭션으로 끝난다 + * (AiToneUsageCommandService는 호출자 트랜잭션 안에서 부르면 예외를 던진다). + *
+ * 원문과 결과는 저장하지도 로그에 남기지도 않는다. 로그에는 회원 번호·문체·결과 종류·소요 시간·출력 토큰 수만 남긴다.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AiToneCorrectionCommandService {
+
+ /** 출력 토큰 상한 (비용 상한). 생각 토큰도 포함되며, 3000자 원문 보고/공문 교정이 4000에서는 잘렸다 (#64 확인) */
+ static final int MAX_OUTPUT_TOKENS = 6000;
+
+ private final UserQueryService userQueryService;
+ private final AiToneUsageCommandService aiToneUsageCommandService;
+ private final LanguageModelClient languageModelClient;
+
+ 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));
+ }
+
+ /** 언어 모델을 부른다. 차단·외부 오류·시간 초과면 선점한 1회를 되돌리고 에러를 던진다. 반환은 이 세 경우에서만 한다. */
+ private LanguageModelResult complete(AiToneCorrectCommand command, AiToneReserveResult reservation) {
+ 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);
+ } catch (LanguageModelException e) {
+ logFailure(command, startedAt, e);
+ throw releaseAndFail(reservation, 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);
+ }
+ return result;
+ }
+
+ /** 반환이 실패해도 원래 에러를 그대로 돌려준다. 로그에는 회원 번호·날짜와 예외 종류만 남긴다. */
+ private BusinessException releaseAndFail(AiToneReserveResult reservation, AiErrorCode errorCode) {
+ try {
+ aiToneUsageCommandService.release(reservation);
+ } catch (RuntimeException e) {
+ log.error("AI 톤 교정 사용량 반환 실패 userId={} usageDate={} exception={}", reservation.userId(),
+ reservation.usageDate(), e.getClass().getSimpleName());
+ }
+ return new BusinessException(errorCode);
+ }
+
+ // 언어 모델 예외 메시지는 상태 코드·오류 종류만 담는다 (원문·응답 본문 없음)
+ private static void logFailure(AiToneCorrectCommand command, long startedAt, RuntimeException e) {
+ log.warn("AI 톤 교정 실패 userId={} style={} elapsedMs={} reason={}", command.userId(), command.style(),
+ elapsedMillis(startedAt), e.getMessage());
+ }
+
+ /** 다시 조회하지 않고 선점 결과로 만든다. 리셋 시각은 선점한 날짜의 다음 날 00:00(KST)이다. */
+ private static AiToneUsageResult usage(AiToneReserveResult reservation) {
+ int limit = UsageLimits.AI_TONE_DAILY_LIMIT;
+ int usedCount = reservation.usedCountAfter();
+ return new AiToneUsageResult(usedCount, limit, Math.max(limit - usedCount, 0),
+ reservation.usageDate().plusDays(1).atStartOfDay());
+ }
+
+ private static long elapsedMillis(long startedAt) {
+ return (System.nanoTime() - startedAt) / 1_000_000;
+ }
+
+}
diff --git a/src/main/java/com/sniptext/domain/ai/service/dto/command/AiToneCorrectCommand.java b/src/main/java/com/sniptext/domain/ai/service/dto/command/AiToneCorrectCommand.java
new file mode 100644
index 0000000..d45c369
--- /dev/null
+++ b/src/main/java/com/sniptext/domain/ai/service/dto/command/AiToneCorrectCommand.java
@@ -0,0 +1,16 @@
+package com.sniptext.domain.ai.service.dto.command;
+
+import com.sniptext.domain.ai.entity.ToneStyle;
+
+/**
+ * AI 톤 교정 입력. 원문은 요청 단계에서 형식 검증을 마친 그대로다.
+ * 원문은 저장·로그 금지 대상이라 toString에는 길이만 보인다.
+ */
+public record AiToneCorrectCommand(Long userId, ToneStyle style, String text) {
+
+ @Override
+ public String toString() {
+ return "AiToneCorrectCommand[userId=%d, style=%s, textLength=%d]".formatted(userId, style, text.length());
+ }
+
+}
diff --git a/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneCorrectResult.java b/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneCorrectResult.java
new file mode 100644
index 0000000..39c64bc
--- /dev/null
+++ b/src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneCorrectResult.java
@@ -0,0 +1,22 @@
+package com.sniptext.domain.ai.service.dto.result;
+
+import com.sniptext.domain.ai.entity.ToneStyle;
+import com.sniptext.domain.ai.support.KeyInfoDiff;
+
+/**
+ * AI 톤 교정 결과. 교정 결과는 저장·로그 금지 대상이라 toString에는 길이만 보인다.
+ *
+ * @param correctedText 앞뒤 공백을 지운 교정 결과
+ * @param keyInfoDiff 원문과 결과의 핵심 정보 차이 (차이가 없으면 두 목록이 빈 값)
+ * @param usage 선점 후 사용량
+ */
+public record AiToneCorrectResult(String correctedText, ToneStyle style, KeyInfoDiff keyInfoDiff,
+ AiToneUsageResult usage) {
+
+ @Override
+ public String toString() {
+ return "AiToneCorrectResult[correctedTextLength=%d, style=%s, keyInfoDiff=%s, usage=%s]".formatted(
+ correctedText.length(), style, keyInfoDiff, usage);
+ }
+
+}
diff --git a/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java b/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java
new file mode 100644
index 0000000..40a29e3
--- /dev/null
+++ b/src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java
@@ -0,0 +1,224 @@
+package com.sniptext.domain.ai.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.willThrow;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+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.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.sniptext.domain.ai.entity.ToneStyle;
+import com.sniptext.domain.ai.exception.AiErrorCode;
+import com.sniptext.domain.ai.service.dto.command.AiToneCorrectCommand;
+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.user.exception.UserErrorCode;
+import com.sniptext.domain.user.service.UserQueryService;
+import com.sniptext.global.client.llm.LanguageModelClient;
+import com.sniptext.global.client.llm.LanguageModelException;
+import com.sniptext.global.client.llm.LanguageModelRequest;
+import com.sniptext.global.client.llm.LanguageModelResult;
+import com.sniptext.global.client.llm.LanguageModelTimeoutException;
+import com.sniptext.global.exception.BusinessException;
+
+@ExtendWith(MockitoExtension.class)
+class AiToneCorrectionCommandServiceTest {
+
+ private static final Long USER_ID = 7L;
+ private static final LocalDate USAGE_DATE = LocalDate.of(2026, 10, 11);
+ private static final String TEXT = "10월 14일까지 300만 원으로 진행 가능하다고 해줘";
+ private static final AiToneCorrectCommand COMMAND = new AiToneCorrectCommand(USER_ID, ToneStyle.BOSS_EXTERNAL, TEXT);
+ private static final AiToneReserveResult RESERVATION = new AiToneReserveResult(USER_ID, USAGE_DATE, 2);
+
+ @Mock
+ private UserQueryService userQueryService;
+
+ @Mock
+ private AiToneUsageCommandService aiToneUsageCommandService;
+
+ @Mock
+ private LanguageModelClient languageModelClient;
+
+ @InjectMocks
+ private AiToneCorrectionCommandService service;
+
+ // 회원 확인에서 끝나는 테스트는 선점까지 가지 않으므로 lenient
+ @BeforeEach
+ void setUp() {
+ lenient().when(aiToneUsageCommandService.reserve(USER_ID)).thenReturn(RESERVATION);
+ }
+
+ @Test
+ @DisplayName("성공하면 앞뒤 공백을 지운 결과와 선점 결과로 만든 사용량을 돌려주고, 반환하지 않는다")
+ void success() {
+ givenModelReturns(" \n안녕하세요. 10월 14일까지 300만 원으로 진행 가능합니다.\n감사합니다. \n", false);
+
+ AiToneCorrectResult result = service.correct(COMMAND);
+
+ assertThat(result.correctedText()).isEqualTo("안녕하세요. 10월 14일까지 300만 원으로 진행 가능합니다.\n감사합니다.");
+ assertThat(result.style()).isEqualTo(ToneStyle.BOSS_EXTERNAL);
+ assertThat(result.keyInfoDiff().hasDifference()).isFalse();
+ assertThat(result.usage()).isEqualTo(new AiToneUsageResult(2, 5, 3, LocalDateTime.of(2026, 10, 12, 0, 0)));
+ verify(aiToneUsageCommandService, never()).release(any());
+ }
+
+ @Test
+ @DisplayName("언어 모델에 문체 지시문,