ai文章润色
This commit is contained in:
+10
@@ -6,6 +6,7 @@ import fun.nojava.module.blog.dto.*;
|
||||
import fun.nojava.module.blog.service.AiSummaryService;
|
||||
import fun.nojava.module.blog.service.ArticleService;
|
||||
import fun.nojava.module.blog.service.TagRecommendService;
|
||||
import fun.nojava.module.blog.service.TextPolishService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -25,6 +26,7 @@ public class ArticleController {
|
||||
private final ArticleService articleService;
|
||||
private final AiSummaryService aiSummaryService;
|
||||
private final TagRecommendService tagRecommendService;
|
||||
private final TextPolishService textPolishService;
|
||||
|
||||
@Operation(summary = "创建文章")
|
||||
@PostMapping
|
||||
@@ -105,4 +107,12 @@ public class ArticleController {
|
||||
return ApiResult.success(aiSummaryService.generateSummary(userId, dto));
|
||||
}
|
||||
|
||||
@Operation(summary = "AI 润色文章文本")
|
||||
@PostMapping("/polish")
|
||||
public ApiResult<PolishResultVO> polishText(
|
||||
@AuthenticationPrincipal Long userId,
|
||||
@Valid @RequestBody PolishTextDTO dto) {
|
||||
return ApiResult.success(textPolishService.polishText(userId, dto));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class DiffSegmentVO {
|
||||
|
||||
private int index;
|
||||
|
||||
private String original;
|
||||
|
||||
private String polished;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class PolishResultVO {
|
||||
|
||||
private String original;
|
||||
|
||||
private String polished;
|
||||
|
||||
private List<DiffSegmentVO> diffSegments;
|
||||
|
||||
private String model;
|
||||
|
||||
private Integer promptTokens;
|
||||
|
||||
private Integer completionTokens;
|
||||
|
||||
private Long costMillis;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PolishTextDTO {
|
||||
|
||||
@NotBlank(message = "文本不能为空")
|
||||
@Size(max = 10000, message = "文本长度不能超过10000")
|
||||
private String text;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package fun.nojava.module.blog.service;
|
||||
|
||||
import fun.nojava.module.blog.dto.PolishResultVO;
|
||||
import fun.nojava.module.blog.dto.PolishTextDTO;
|
||||
|
||||
public interface TextPolishService {
|
||||
|
||||
PolishResultVO polishText(Long userId, PolishTextDTO dto);
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
import fun.nojava.common.exception.ErrorCode;
|
||||
import fun.nojava.module.blog.config.DashScopeProperties;
|
||||
import fun.nojava.module.blog.dto.DiffSegmentVO;
|
||||
import fun.nojava.module.blog.dto.PolishResultVO;
|
||||
import fun.nojava.module.blog.dto.PolishTextDTO;
|
||||
import fun.nojava.module.blog.service.TextPolishService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TextPolishServiceImpl implements TextPolishService {
|
||||
|
||||
private static final int MIN_TEXT_LENGTH = 20;
|
||||
private static final String RATE_LIMIT_KEY_PREFIX = "text_polish:rate:";
|
||||
|
||||
private static final String SYSTEM_PROMPT =
|
||||
"你是一名专业的文字编辑,请润色用户提供的中文文本。" +
|
||||
"修正语法错误、优化表达方式、规范标点符号,保持原意不变,使文本更流畅专业。" +
|
||||
"直接输出润色后的文本,不要添加任何解释或标记。";
|
||||
|
||||
private final AiSummaryServiceImpl aiSummaryService;
|
||||
private final DashScopeProperties properties;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
@Override
|
||||
public PolishResultVO polishText(Long userId, PolishTextDTO dto) {
|
||||
String text = dto.getText().trim();
|
||||
if (text.length() < MIN_TEXT_LENGTH) {
|
||||
throw new BusinessException(ErrorCode.AI_CONTENT_TOO_SHORT);
|
||||
}
|
||||
if (!hasApiKey()) {
|
||||
throw new BusinessException(ErrorCode.AI_API_KEY_MISSING);
|
||||
}
|
||||
checkRateLimit(userId);
|
||||
|
||||
String input = text.length() > properties.getMaxInput()
|
||||
? text.substring(0, properties.getMaxInput())
|
||||
: text;
|
||||
|
||||
String userPrompt = "请润色下面这段中文文本:\n" + input;
|
||||
long startMillis = System.currentTimeMillis();
|
||||
|
||||
Map<String, Object> requestBody = Map.of(
|
||||
"model", properties.getModel(),
|
||||
"messages", List.of(
|
||||
Map.of("role", "system", "content", SYSTEM_PROMPT),
|
||||
Map.of("role", "user", "content", userPrompt)
|
||||
),
|
||||
"temperature", 0.3
|
||||
);
|
||||
|
||||
Map<String, Object> response;
|
||||
try {
|
||||
response = aiSummaryService.callModel(requestBody);
|
||||
} catch (ResourceAccessException ex) {
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
log.error("文本润色 AI 调用超时, userId={}", userId);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_TIMEOUT);
|
||||
}
|
||||
log.error("文本润色 AI 网络异常, userId={}, error={}", userId, ex.getMessage());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (RestClientResponseException ex) {
|
||||
log.error("文本润色 AI HTTP 异常, status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.error("文本润色 AI 调用失败, userId={}, error={}", userId, ex.getMessage(), ex);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
long costMillis = System.currentTimeMillis() - startMillis;
|
||||
String polishedText = extractContent(response);
|
||||
List<DiffSegmentVO> segments = alignParagraphs(text, polishedText);
|
||||
|
||||
Integer promptTokens = null;
|
||||
Integer completionTokens = null;
|
||||
Object usageObj = response.get("usage");
|
||||
if (usageObj instanceof Map<?, ?> usage) {
|
||||
promptTokens = toInt(usage.get("prompt_tokens"));
|
||||
completionTokens = toInt(usage.get("completion_tokens"));
|
||||
}
|
||||
|
||||
String model = (String) response.getOrDefault("model", properties.getModel());
|
||||
|
||||
log.info("文本润色成功 userId={}, model={}, origLen={}, polishedLen={}, cost={}ms",
|
||||
userId, model, text.length(), polishedText.length(), costMillis);
|
||||
|
||||
return PolishResultVO.builder()
|
||||
.original(text)
|
||||
.polished(polishedText)
|
||||
.diffSegments(segments)
|
||||
.model(model)
|
||||
.promptTokens(promptTokens)
|
||||
.completionTokens(completionTokens)
|
||||
.costMillis(costMillis)
|
||||
.build();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String extractContent(Map<String, Object> response) {
|
||||
if (response == null) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
|
||||
if (choices == null || choices.isEmpty()) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
|
||||
String content = message == null ? null : (String) message.get("content");
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
List<DiffSegmentVO> alignParagraphs(String original, String polished) {
|
||||
String[] origParas = original.split("\n\n");
|
||||
String[] polParas = polished.split("\n\n");
|
||||
|
||||
int origLen = origParas.length;
|
||||
int polLen = polParas.length;
|
||||
|
||||
// 段落数一致:一一对应
|
||||
if (origLen == polLen) {
|
||||
List<DiffSegmentVO> segments = new ArrayList<>();
|
||||
for (int i = 0; i < origLen; i++) {
|
||||
String o = origParas[i].trim();
|
||||
String p = polParas[i].trim();
|
||||
if (o.isEmpty() && p.isEmpty()) continue;
|
||||
segments.add(DiffSegmentVO.builder()
|
||||
.index(i)
|
||||
.original(o)
|
||||
.polished(p)
|
||||
.build());
|
||||
}
|
||||
return segments.isEmpty()
|
||||
? List.of(DiffSegmentVO.builder().index(0).original(original).polished(polished).build())
|
||||
: segments;
|
||||
}
|
||||
|
||||
// 段数差异过大(超过 2 倍):降级为全文单段对比
|
||||
if (polLen > origLen * 2 || origLen > polLen * 2) {
|
||||
return List.of(DiffSegmentVO.builder()
|
||||
.index(0)
|
||||
.original(original)
|
||||
.polished(polished)
|
||||
.build());
|
||||
}
|
||||
|
||||
// 段数略有差异:按较少段落数对齐,多余内容合并到最后一段
|
||||
int minLen = Math.min(origLen, polLen);
|
||||
List<DiffSegmentVO> segments = new ArrayList<>();
|
||||
for (int i = 0; i < minLen; i++) {
|
||||
String o = origParas[i].trim();
|
||||
String p = polParas[i].trim();
|
||||
if (o.isEmpty() && p.isEmpty()) continue;
|
||||
segments.add(DiffSegmentVO.builder()
|
||||
.index(i)
|
||||
.original(o)
|
||||
.polished(p)
|
||||
.build());
|
||||
}
|
||||
|
||||
// 多余的原文段落合并
|
||||
if (origLen > minLen) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = minLen; i < origLen; i++) {
|
||||
if (sb.length() > 0) sb.append("\n\n");
|
||||
sb.append(origParas[i].trim());
|
||||
}
|
||||
String lastO = sb.toString();
|
||||
if (!lastO.isEmpty()) {
|
||||
int lastIdx = segments.size();
|
||||
String lastP = segments.isEmpty() ? polished : segments.get(segments.size() - 1).getPolished();
|
||||
segments.add(DiffSegmentVO.builder()
|
||||
.index(lastIdx)
|
||||
.original(lastO)
|
||||
.polished(lastP)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
return segments.isEmpty()
|
||||
? List.of(DiffSegmentVO.builder().index(0).original(original).polished(polished).build())
|
||||
: segments;
|
||||
}
|
||||
|
||||
private boolean hasApiKey() {
|
||||
return properties.getApiKey() != null && !properties.getApiKey().isBlank();
|
||||
}
|
||||
|
||||
private void checkRateLimit(Long userId) {
|
||||
if (userId == null) return;
|
||||
String key = RATE_LIMIT_KEY_PREFIX + userId;
|
||||
Long count = redisTemplate.opsForValue().increment(key);
|
||||
if (count != null && count == 1L) {
|
||||
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
|
||||
}
|
||||
if (count != null && count > properties.getRateLimitPerMinute()) {
|
||||
log.warn("文本润色限流命中 userId={}, count={}", userId, count);
|
||||
throw new BusinessException(ErrorCode.RATE_LIMITED);
|
||||
}
|
||||
}
|
||||
|
||||
private Integer toInt(Object value) {
|
||||
if (value instanceof Number n) return n.intValue();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
import fun.nojava.common.exception.ErrorCode;
|
||||
import fun.nojava.module.blog.config.DashScopeProperties;
|
||||
import fun.nojava.module.blog.dto.DiffSegmentVO;
|
||||
import fun.nojava.module.blog.dto.PolishResultVO;
|
||||
import fun.nojava.module.blog.dto.PolishTextDTO;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class TextPolishServiceImplTest {
|
||||
|
||||
private DashScopeProperties properties;
|
||||
private StringRedisTemplate redisTemplate;
|
||||
private ValueOperations<String, String> valueOps;
|
||||
private AiSummaryServiceImpl mockAiSummaryService;
|
||||
private TextPolishServiceImpl service;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new DashScopeProperties();
|
||||
properties.setApiKey("test-key");
|
||||
properties.setBaseUrl("https://example.com/v1");
|
||||
properties.setModel("qwen-turbo");
|
||||
properties.setTimeout(30000);
|
||||
properties.setMaxInput(6000);
|
||||
properties.setRateLimitPerMinute(5);
|
||||
|
||||
redisTemplate = mock(StringRedisTemplate.class);
|
||||
valueOps = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||
when(valueOps.increment(anyString())).thenReturn(1L);
|
||||
|
||||
mockAiSummaryService = mock(AiSummaryServiceImpl.class);
|
||||
|
||||
service = new TextPolishServiceImpl(mockAiSummaryService, properties, redisTemplate);
|
||||
}
|
||||
|
||||
// ==================== P0 测试 ====================
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-01: 文本过短(<20字符)应抛出 AI_CONTENT_TOO_SHORT")
|
||||
void shouldRejectWhenTextTooShort() {
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("太短了");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.polishText(1L, dto));
|
||||
assertEquals(ErrorCode.AI_CONTENT_TOO_SHORT.getCode(), ex.getCode());
|
||||
verify(valueOps, never()).increment(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-02: 未配置 API Key 应抛出 AI_API_KEY_MISSING")
|
||||
void shouldRejectWhenApiKeyMissing() {
|
||||
properties.setApiKey("");
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.polishText(1L, dto));
|
||||
assertEquals(ErrorCode.AI_API_KEY_MISSING.getCode(), ex.getCode());
|
||||
verify(valueOps, never()).increment(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-03: 单用户超过限流应触发 RATE_LIMITED")
|
||||
void shouldThrowRateLimitedWhenExceedsLimit() {
|
||||
when(valueOps.increment("text_polish:rate:7")).thenReturn(6L);
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.polishText(7L, dto));
|
||||
assertEquals(ErrorCode.RATE_LIMITED.getCode(), ex.getCode());
|
||||
verify(mockAiSummaryService, never()).callModel(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-04: 正常返回应填充 original、polished、diffSegments、model、tokens、耗时")
|
||||
void shouldReturnPolishResultOnSuccess() {
|
||||
String originalText = "这是一篇关于微服务架构的详细技术文章,涵盖服务注册与发现、配置中心等内容。";
|
||||
String polishedText = "这是一篇关于微服务架构的详细技术文章,覆盖了服务注册与发现、配置中心等内容。";
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse(polishedText));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText(originalText);
|
||||
|
||||
PolishResultVO result = service.polishText(1L, dto);
|
||||
|
||||
assertNotNull(result.getOriginal());
|
||||
assertEquals(originalText, result.getOriginal());
|
||||
assertEquals(polishedText, result.getPolished());
|
||||
assertNotNull(result.getDiffSegments());
|
||||
assertFalse(result.getDiffSegments().isEmpty());
|
||||
assertEquals(0, result.getDiffSegments().get(0).getIndex());
|
||||
assertEquals(originalText, result.getDiffSegments().get(0).getOriginal());
|
||||
assertEquals(polishedText, result.getDiffSegments().get(0).getPolished());
|
||||
assertEquals("qwen-turbo", result.getModel());
|
||||
assertEquals(123, result.getPromptTokens());
|
||||
assertEquals(45, result.getCompletionTokens());
|
||||
assertNotNull(result.getCostMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-05: 多段落应正确对齐到 diffSegments")
|
||||
void shouldAlignMultipleParagraphs() {
|
||||
String originalText = "第一段内容。\n\n第二段内容。\n\n第三段内容。";
|
||||
String polishedText = "第一段润色后。\n\n第二段润色后。\n\n第三段润色后。";
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse(polishedText));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText(originalText);
|
||||
|
||||
PolishResultVO result = service.polishText(1L, dto);
|
||||
|
||||
assertEquals(3, result.getDiffSegments().size());
|
||||
assertEquals("第一段内容。", result.getDiffSegments().get(0).getOriginal());
|
||||
assertEquals("第一段润色后。", result.getDiffSegments().get(0).getPolished());
|
||||
assertEquals("第二段内容。", result.getDiffSegments().get(1).getOriginal());
|
||||
assertEquals("第二段润色后。", result.getDiffSegments().get(1).getPolished());
|
||||
assertEquals("第三段内容。", result.getDiffSegments().get(2).getOriginal());
|
||||
assertEquals("第三段润色后。", result.getDiffSegments().get(2).getPolished());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-06: choices 为空应映射为 AI_SERVICE_UNAVAILABLE")
|
||||
void shouldFailWhenChoicesEmpty() {
|
||||
Map<String, Object> response = new java.util.HashMap<>();
|
||||
response.put("choices", List.of());
|
||||
response.put("model", "qwen-turbo");
|
||||
when(mockAiSummaryService.callModel(any())).thenReturn(response);
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.polishText(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-07: 模型调用异常应映射为 AI_SERVICE_UNAVAILABLE")
|
||||
void shouldMapExceptionToAiServiceUnavailable() {
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenThrow(new RuntimeException("网络异常"));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.polishText(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-08: 长文本应截断到 maxInput 上限")
|
||||
void shouldTruncateLongTextToMaxInput() {
|
||||
properties.setMaxInput(50);
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse("润色后的文本内容。"));
|
||||
String longText = "X".repeat(500);
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText(longText);
|
||||
|
||||
service.polishText(1L, dto);
|
||||
|
||||
verify(mockAiSummaryService).callModel(argThat(requestBody -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> messages = (List<Map<String, Object>>) requestBody.get("messages");
|
||||
String userContent = (String) messages.get(1).get("content");
|
||||
long xCount = userContent.chars().filter(c -> c == 'X').count();
|
||||
assertEquals(50, xCount, "文本应截断到 maxInput 字符");
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-09: 首次调用应设置限流 key 的 1 分钟 TTL")
|
||||
void shouldExpireRateLimitKeyOnFirstCall() {
|
||||
when(valueOps.increment("text_polish:rate:8")).thenReturn(1L);
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse("润色后的文本内容。"));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
service.polishText(8L, dto);
|
||||
|
||||
verify(redisTemplate, times(1)).expire(eq("text_polish:rate:8"), eq(1L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-POLISH-10: userId 为 null 时不应限流,应正常调用模型")
|
||||
void shouldNotRateLimitWhenUserIdNull() {
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse("润色后的文本内容。"));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
PolishResultVO result = service.polishText(null, dto);
|
||||
|
||||
assertNotNull(result.getPolished());
|
||||
verify(mockAiSummaryService).callModel(any());
|
||||
}
|
||||
|
||||
// ==================== P1 测试 ====================
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-11: 模型返回 content 为 blank 应映射为 AI_SERVICE_UNAVAILABLE")
|
||||
void shouldFailWhenContentIsBlank() {
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse(" "));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.polishText(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-12: 模型返回 content 为 null 应映射为 AI_SERVICE_UNAVAILABLE")
|
||||
void shouldFailWhenContentIsNull() {
|
||||
Map<String, Object> response = new java.util.HashMap<>();
|
||||
response.put("model", "qwen-turbo");
|
||||
Map<String, Object> message = new java.util.HashMap<>();
|
||||
message.put("role", "assistant");
|
||||
message.put("content", null);
|
||||
response.put("choices", List.of(Map.of("message", message)));
|
||||
when(mockAiSummaryService.callModel(any())).thenReturn(response);
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.polishText(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-13: 响应中无 usage 字段时 tokens 应为 null")
|
||||
void shouldReturnNullTokensWhenNoUsage() {
|
||||
Map<String, Object> response = Map.of(
|
||||
"model", "qwen-turbo",
|
||||
"choices", List.of(
|
||||
Map.of("message", Map.of("role", "assistant", "content", "润色后的文本。"))
|
||||
)
|
||||
);
|
||||
when(mockAiSummaryService.callModel(any())).thenReturn(response);
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
PolishResultVO result = service.polishText(1L, dto);
|
||||
|
||||
assertNull(result.getPromptTokens());
|
||||
assertNull(result.getCompletionTokens());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-14: 段落数差异超过 2 倍应降级为全文单段对比(原文段落多)")
|
||||
void shouldFallbackWhenParagraphCountDiffersTooMuchMoreOriginal() {
|
||||
String originalText = "第一段落内容足够长。\n\n第二段落内容足够长。\n\n第三段落内容足够长。\n\n第四段落内容足够长。\n\n第五段落内容足够长。";
|
||||
String polishedText = "全部合并为一段润色后的文本内容。";
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse(polishedText));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText(originalText);
|
||||
|
||||
PolishResultVO result = service.polishText(1L, dto);
|
||||
|
||||
assertEquals(1, result.getDiffSegments().size(), "应降级为单段对比");
|
||||
assertEquals(originalText, result.getDiffSegments().get(0).getOriginal());
|
||||
assertEquals(polishedText, result.getDiffSegments().get(0).getPolished());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-15: 段落数差异超过 2 倍应降级为全文单段对比(润色段落多)")
|
||||
void shouldFallbackWhenParagraphCountDiffersTooMuchMorePolished() {
|
||||
String originalText = "只有一段但足够长的文本内容用于润色处理。";
|
||||
String polishedText = "润色后第一段文本内容足够长。\n\n润色后第二段文本内容足够长。\n\n润色后第三段文本内容足够长。\n\n润色后第四段文本内容足够长。";
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse(polishedText));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText(originalText);
|
||||
|
||||
PolishResultVO result = service.polishText(1L, dto);
|
||||
|
||||
assertEquals(1, result.getDiffSegments().size(), "应降级为单段对比");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-16: 段落数略有差异(不超过2倍)时按较少段落数对齐")
|
||||
void shouldAlignToMinParagraphsWhenSlightDifference() {
|
||||
String originalText = "第一篇原文内容足够长。\n\n第二篇原文内容足够长。";
|
||||
String polishedText = "第一篇润色后内容足够长。\n\n第二篇润色后内容足够长。\n\n多余的第三段落内容。";
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse(polishedText));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText(originalText);
|
||||
|
||||
PolishResultVO result = service.polishText(1L, dto);
|
||||
|
||||
assertEquals(2, result.getDiffSegments().size(), "应按较少段落数(2)对齐");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-17: 单段文本正常返回")
|
||||
void shouldWorkWithSingleParagraph() {
|
||||
String originalText = "这是一段需要润色的单段文本内容,存在一些语法和表达的问题。";
|
||||
String polishedText = "这是一段已润色的单段文本内容,修正了语法和表达的问题。";
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse(polishedText));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText(originalText);
|
||||
|
||||
PolishResultVO result = service.polishText(1L, dto);
|
||||
|
||||
assertEquals(1, result.getDiffSegments().size());
|
||||
assertEquals(originalText, result.getDiffSegments().get(0).getOriginal());
|
||||
assertEquals(polishedText, result.getDiffSegments().get(0).getPolished());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-18: 包含空段落的文本应过滤空段")
|
||||
void shouldFilterEmptyParagraphs() {
|
||||
String originalText = "第一段落内容足够长。\n\n\n\n第二段落内容足够长。";
|
||||
String polishedText = "第一段润色后内容。\n\n第二段润色后内容。";
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse(polishedText));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText(originalText);
|
||||
|
||||
PolishResultVO result = service.polishText(1L, dto);
|
||||
|
||||
assertFalse(result.getDiffSegments().isEmpty());
|
||||
// 过滤后应有2段有效内容
|
||||
assertTrue(result.getDiffSegments().size() >= 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-19: 文本前后空格应被 trim")
|
||||
void shouldTrimTextBeforeProcessing() {
|
||||
String originalText = " 这是一段前后有空格的文本,足够长以通过长度校验测试。 ";
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse("润色后的内容。"));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText(originalText);
|
||||
|
||||
PolishResultVO result = service.polishText(1L, dto);
|
||||
|
||||
assertFalse(result.getOriginal().startsWith(" "), "开头空格应被 trim");
|
||||
assertFalse(result.getOriginal().endsWith(" "), "结尾空格应被 trim");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-20: response 为 null 应映射为 AI_SERVICE_UNAVAILABLE")
|
||||
void shouldFailWhenResponseIsNull() {
|
||||
when(mockAiSummaryService.callModel(any())).thenReturn(null);
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.polishText(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-21: choices 中 message 为 null 应映射为 AI_SERVICE_UNAVAILABLE")
|
||||
void shouldFailWhenMessageIsNull() {
|
||||
Map<String, Object> response = new java.util.HashMap<>();
|
||||
response.put("model", "qwen-turbo");
|
||||
Map<String, Object> choice = new java.util.HashMap<>();
|
||||
choice.put("message", null);
|
||||
response.put("choices", List.of(choice));
|
||||
when(mockAiSummaryService.callModel(any())).thenReturn(response);
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.polishText(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-22: 传递的 model、temperature、messages 结构应正确")
|
||||
void shouldBuildCorrectModelRequestBody() {
|
||||
when(mockAiSummaryService.callModel(any()))
|
||||
.thenReturn(buildModelResponse("润色后的文本。"));
|
||||
|
||||
PolishTextDTO dto = new PolishTextDTO();
|
||||
dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。");
|
||||
|
||||
service.polishText(1L, dto);
|
||||
|
||||
verify(mockAiSummaryService).callModel(argThat(requestBody -> {
|
||||
assertEquals("qwen-turbo", requestBody.get("model"));
|
||||
assertEquals(0.3, requestBody.get("temperature"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> messages = (List<Map<String, Object>>) requestBody.get("messages");
|
||||
assertEquals(2, messages.size());
|
||||
assertEquals("system", messages.get(0).get("role"));
|
||||
assertEquals("user", messages.get(1).get("role"));
|
||||
String userContent = (String) messages.get(1).get("content");
|
||||
assertTrue(userContent.contains("请润色下面这段中文文本"), "User prompt 应包含润色指令");
|
||||
assertTrue(userContent.contains("测试用例验证"), "User prompt 应包含正文内容");
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
// ==================== 段落对齐专项测试 ====================
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-ALIGN-01: 原文与润色段落数相等——一一对应")
|
||||
void alignParagraphs_equalCount() {
|
||||
List<DiffSegmentVO> result = service.alignParagraphs("A\n\nB\n\nC", "A'\n\nB'\n\nC'");
|
||||
assertEquals(3, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-ALIGN-02: 润色结果完全一致时仍返回对比")
|
||||
void alignParagraphs_identicalText() {
|
||||
List<DiffSegmentVO> result = service.alignParagraphs("相同内容", "相同内容");
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("相同内容", result.get(0).getOriginal());
|
||||
assertEquals("相同内容", result.get(0).getPolished());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-POLISH-ALIGN-03: 原文和润色结果均为空时应降级返回")
|
||||
void alignParagraphs_bothEmpty() {
|
||||
List<DiffSegmentVO> result = service.alignParagraphs("", "");
|
||||
assertEquals(1, result.size());
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private Map<String, Object> buildModelResponse(String polishedText) {
|
||||
return Map.of(
|
||||
"model", "qwen-turbo",
|
||||
"choices", List.of(
|
||||
Map.of("message", Map.of("role", "assistant", "content", polishedText))
|
||||
),
|
||||
"usage", Map.of(
|
||||
"prompt_tokens", 123,
|
||||
"completion_tokens", 45,
|
||||
"total_tokens", 168
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user