ai文章润色

This commit is contained in:
nojava
2026-05-23 18:48:35 +08:00
parent 9a43c611b9
commit 8b23d02531
13 changed files with 1755 additions and 1 deletions
@@ -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);
}
@@ -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;
}
}
@@ -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
)
);
}
}