集成AI摘要
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package fun.nojava.module.blog.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 阿里云百炼(DashScope)大模型平台配置。
|
||||
* <p>
|
||||
* 通过 OpenAI 兼容模式接入,敏感的 {@code apiKey} 必须通过环境变量
|
||||
* {@code DASHSCOPE_API_KEY} 注入,禁止入仓。
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "dashscope")
|
||||
public class DashScopeProperties {
|
||||
|
||||
/** API Key(必填,仅可通过环境变量注入) */
|
||||
private String apiKey = "";
|
||||
|
||||
/** OpenAI 兼容模式 BaseURL */
|
||||
private String baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
|
||||
/** 默认模型 */
|
||||
private String model = "qwen-turbo";
|
||||
|
||||
/** 单次调用超时(毫秒) */
|
||||
private int timeout = 30000;
|
||||
|
||||
/** 单次输入正文字符上限 */
|
||||
private int maxInput = 8000;
|
||||
|
||||
/** 单用户每分钟调用次数上限 */
|
||||
private int rateLimitPerMinute = 5;
|
||||
}
|
||||
+10
@@ -3,6 +3,7 @@ package fun.nojava.module.blog.controller;
|
||||
import fun.nojava.common.model.ApiResult;
|
||||
import fun.nojava.common.model.PageResult;
|
||||
import fun.nojava.module.blog.dto.*;
|
||||
import fun.nojava.module.blog.service.AiSummaryService;
|
||||
import fun.nojava.module.blog.service.ArticleService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
public class ArticleController {
|
||||
|
||||
private final ArticleService articleService;
|
||||
private final AiSummaryService aiSummaryService;
|
||||
|
||||
@Operation(summary = "创建文章")
|
||||
@PostMapping
|
||||
@@ -81,4 +83,12 @@ public class ArticleController {
|
||||
@AuthenticationPrincipal Long userId) {
|
||||
return ApiResult.success(articleService.toggleLike(userId, id));
|
||||
}
|
||||
|
||||
@Operation(summary = "AI 生成文章摘要")
|
||||
@PostMapping("/summary")
|
||||
public ApiResult<SummaryResultVO> generateSummary(
|
||||
@AuthenticationPrincipal Long userId,
|
||||
@Valid @RequestBody GenerateSummaryDTO dto) {
|
||||
return ApiResult.success(aiSummaryService.generateSummary(userId, dto));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class GenerateSummaryDTO {
|
||||
|
||||
@Size(max = 200, message = "标题最长200字符")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "内容不能为空")
|
||||
@Size(max = 50000, message = "正文过长")
|
||||
private String content;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class SummaryResultVO {
|
||||
|
||||
private String summary;
|
||||
|
||||
private String model;
|
||||
|
||||
private Integer promptTokens;
|
||||
|
||||
private Integer completionTokens;
|
||||
|
||||
private Long costMillis;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package fun.nojava.module.blog.service;
|
||||
|
||||
import fun.nojava.module.blog.dto.GenerateSummaryDTO;
|
||||
import fun.nojava.module.blog.dto.SummaryResultVO;
|
||||
|
||||
public interface AiSummaryService {
|
||||
|
||||
SummaryResultVO generateSummary(Long userId, GenerateSummaryDTO dto);
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
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.GenerateSummaryDTO;
|
||||
import fun.nojava.module.blog.dto.SummaryResultVO;
|
||||
import fun.nojava.module.blog.service.AiSummaryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 阿里云百炼(DashScope)OpenAI 兼容模式摘要生成服务。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiSummaryServiceImpl implements AiSummaryService {
|
||||
|
||||
private static final int MIN_CONTENT_LENGTH = 20;
|
||||
private static final int SUMMARY_MAX_LENGTH = 200;
|
||||
private static final String SUMMARY_TRUNCATE_SUFFIX = "…";
|
||||
private static final String RATE_LIMIT_KEY_PREFIX = "ai_summary:rate:";
|
||||
|
||||
private static final String SYSTEM_PROMPT =
|
||||
"你是一名专业的博客编辑,请根据用户提供的标题与正文生成 100~150 字的中文摘要。" +
|
||||
"要求:语言精炼、概括文章核心观点、不使用第一人称、不要出现\"本文\"以外的元描述、" +
|
||||
"直接输出摘要正文,不要加任何前缀或引号。";
|
||||
|
||||
private final DashScopeProperties properties;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
private RestClient restClient;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout((int) Duration.ofMillis(properties.getTimeout()).toMillis());
|
||||
factory.setReadTimeout((int) Duration.ofMillis(properties.getTimeout()).toMillis());
|
||||
this.restClient = RestClient.builder()
|
||||
.baseUrl(properties.getBaseUrl())
|
||||
.requestFactory(factory)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SummaryResultVO generateSummary(Long userId, GenerateSummaryDTO dto) {
|
||||
String plainContent = stripHtml(dto.getContent());
|
||||
if (plainContent.length() < MIN_CONTENT_LENGTH) {
|
||||
throw new BusinessException(ErrorCode.AI_CONTENT_TOO_SHORT);
|
||||
}
|
||||
if (!hasApiKey()) {
|
||||
throw new BusinessException(ErrorCode.AI_API_KEY_MISSING);
|
||||
}
|
||||
checkRateLimit(userId);
|
||||
|
||||
String input = plainContent.length() > properties.getMaxInput()
|
||||
? plainContent.substring(0, properties.getMaxInput())
|
||||
: plainContent;
|
||||
|
||||
String userPrompt = buildUserPrompt(dto.getTitle(), 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,
|
||||
"top_p", 0.9
|
||||
);
|
||||
|
||||
Map<String, Object> response;
|
||||
try {
|
||||
response = callModel(requestBody);
|
||||
} catch (ResourceAccessException ex) {
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
log.error("DashScope 调用超时, userId={}, cost={}ms", userId, System.currentTimeMillis() - startMillis);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_TIMEOUT);
|
||||
}
|
||||
log.error("DashScope 网络异常, userId={}, error={}", userId, ex.getMessage());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (RestClientResponseException ex) {
|
||||
log.error("DashScope HTTP 异常, status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (Exception ex) {
|
||||
log.error("DashScope 未知异常, userId={}, error={}", userId, ex.getMessage(), ex);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
long costMillis = System.currentTimeMillis() - startMillis;
|
||||
SummaryResultVO result = parseResponse(response, costMillis);
|
||||
log.info("AI 摘要生成成功 userId={}, model={}, promptTokens={}, completionTokens={}, cost={}ms",
|
||||
userId, result.getModel(), result.getPromptTokens(), result.getCompletionTokens(), costMillis);
|
||||
return result;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
protected Map<String, Object> callModel(Map<String, Object> requestBody) {
|
||||
return restClient.post()
|
||||
.uri("/chat/completions")
|
||||
.header("Authorization", "Bearer " + properties.getApiKey())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(requestBody)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
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("AI 摘要限流命中 userId={}, count={}", userId, count);
|
||||
throw new BusinessException(ErrorCode.RATE_LIMITED);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildUserPrompt(String title, String content) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (title != null && !title.isBlank()) {
|
||||
sb.append("标题:").append(title.trim()).append("\n");
|
||||
}
|
||||
sb.append("正文:\n").append(content);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private SummaryResultVO parseResponse(Map<String, Object> response, long costMillis) {
|
||||
if (response == null) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
|
||||
if (choices == null || choices.isEmpty()) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
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);
|
||||
}
|
||||
|
||||
String summary = truncate(content.trim());
|
||||
|
||||
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());
|
||||
|
||||
return SummaryResultVO.builder()
|
||||
.summary(summary)
|
||||
.model(model)
|
||||
.promptTokens(promptTokens)
|
||||
.completionTokens(completionTokens)
|
||||
.costMillis(costMillis)
|
||||
.build();
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.error("解析 DashScope 响应失败 error={}", ex.getMessage(), ex);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private Integer toInt(Object value) {
|
||||
if (value instanceof Number n) {
|
||||
return n.intValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String truncate(String text) {
|
||||
if (text.length() <= SUMMARY_MAX_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, SUMMARY_MAX_LENGTH - 1) + SUMMARY_TRUNCATE_SUFFIX;
|
||||
}
|
||||
|
||||
private String stripHtml(String content) {
|
||||
if (content == null) {
|
||||
return "";
|
||||
}
|
||||
String text = content.replaceAll("(?is)<script[^>]*>.*?</script>", " ")
|
||||
.replaceAll("(?is)<style[^>]*>.*?</style>", " ")
|
||||
.replaceAll("<[^>]+>", " ")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"");
|
||||
return text.replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
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.GenerateSummaryDTO;
|
||||
import fun.nojava.module.blog.dto.SummaryResultVO;
|
||||
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.mockito.Mockito;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AiSummaryServiceImplTest {
|
||||
|
||||
private DashScopeProperties properties;
|
||||
private StringRedisTemplate redisTemplate;
|
||||
private ValueOperations<String, String> valueOps;
|
||||
private TestableAiSummaryService 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(8000);
|
||||
properties.setRateLimitPerMinute(5);
|
||||
|
||||
redisTemplate = mock(StringRedisTemplate.class);
|
||||
valueOps = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||
when(valueOps.increment(anyString())).thenReturn(1L);
|
||||
|
||||
service = new TestableAiSummaryService(properties, redisTemplate);
|
||||
service.init();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-01: 正文过短应抛出 AI_CONTENT_TOO_SHORT")
|
||||
void shouldRejectWhenContentTooShort() {
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setTitle("t");
|
||||
dto.setContent("太短了");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_CONTENT_TOO_SHORT.getCode(), ex.getCode());
|
||||
verify(valueOps, never()).increment(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-02: 未配置 API Key 应抛出 AI_API_KEY_MISSING")
|
||||
void shouldRejectWhenApiKeyMissing() {
|
||||
properties.setApiKey("");
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_API_KEY_MISSING.getCode(), ex.getCode());
|
||||
verify(valueOps, never()).increment(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-03: 单用户每分钟超过 5 次应触发 RATE_LIMITED")
|
||||
void shouldThrowRateLimitedWhenExceedsLimit() {
|
||||
when(valueOps.increment("ai_summary:rate:7")).thenReturn(6L);
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(7L, dto));
|
||||
assertEquals(ErrorCode.RATE_LIMITED.getCode(), ex.getCode());
|
||||
service.assertNoCall();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-04: 首次调用应设置 1 分钟 TTL")
|
||||
void shouldExpireRateLimitKeyOnFirstCall() {
|
||||
when(valueOps.increment("ai_summary:rate:8")).thenReturn(1L);
|
||||
service.stubResponse(buildModelResponse("一段满足长度要求的摘要内容,文字精炼概括核心观点。"));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
service.generateSummary(8L, dto);
|
||||
|
||||
verify(redisTemplate, times(1)).expire(eq("ai_summary:rate:8"), eq(1L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-05: 正常返回应填充摘要、模型、tokens、耗时")
|
||||
void shouldReturnSummaryOnSuccess() {
|
||||
service.stubResponse(buildModelResponse("这是一段长度合适的摘要内容,对全文做了精炼概括。"));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setTitle("标题");
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
SummaryResultVO result = service.generateSummary(1L, dto);
|
||||
|
||||
assertNotNull(result.getSummary());
|
||||
assertEquals("qwen-turbo", result.getModel());
|
||||
assertEquals(123, result.getPromptTokens());
|
||||
assertEquals(45, result.getCompletionTokens());
|
||||
assertNotNull(result.getCostMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-06: 正文中的 HTML 标签应被剥离后再送入模型")
|
||||
void shouldStripHtmlBeforeCallingModel() {
|
||||
AtomicReference<Map<String, Object>> captured = new AtomicReference<>();
|
||||
service.stubResponseWithCapture(buildModelResponse("摘要内容长度合规并完整。"), captured::set);
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("<p>这是 <strong>HTML</strong> 富文本内容,足够长以通过长度校验。</p>");
|
||||
|
||||
service.generateSummary(1L, dto);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> messages = (List<Map<String, Object>>) captured.get().get("messages");
|
||||
String userContent = (String) messages.get(1).get("content");
|
||||
assertFalse(userContent.contains("<p>"), "HTML 标签未被剥离: " + userContent);
|
||||
assertFalse(userContent.contains("<strong>"), "HTML 标签未被剥离: " + userContent);
|
||||
assertTrue(userContent.contains("HTML"), "纯文本内容缺失");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-07: 长正文应被截断到 maxInput 上限")
|
||||
void shouldTruncateLongContentToMaxInput() {
|
||||
properties.setMaxInput(50);
|
||||
AtomicReference<Map<String, Object>> captured = new AtomicReference<>();
|
||||
service.stubResponseWithCapture(buildModelResponse("摘要内容长度合规并完整。"), captured::set);
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("A".repeat(500));
|
||||
|
||||
service.generateSummary(1L, dto);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> messages = (List<Map<String, Object>>) captured.get().get("messages");
|
||||
String userContent = (String) messages.get(1).get("content");
|
||||
long aCount = userContent.chars().filter(c -> c == 'A').count();
|
||||
assertEquals(50, aCount, "正文截断到 maxInput 字符未生效");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-08: 模型返回超长内容应截断至 200 字符并加省略号")
|
||||
void shouldTruncateLongModelOutput() {
|
||||
String longSummary = "摘".repeat(300);
|
||||
service.stubResponse(buildModelResponse(longSummary));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
SummaryResultVO result = service.generateSummary(1L, dto);
|
||||
|
||||
assertEquals(200, result.getSummary().length());
|
||||
assertTrue(result.getSummary().endsWith("…"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-09: choices 为空应映射为 AI_SERVICE_UNAVAILABLE")
|
||||
void shouldFailWhenChoicesEmpty() {
|
||||
Map<String, Object> response = new java.util.HashMap<>();
|
||||
response.put("choices", List.of());
|
||||
response.put("model", "qwen-turbo");
|
||||
service.stubResponse(response);
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-10: SocketTimeoutException 应映射为 AI_SERVICE_TIMEOUT")
|
||||
void shouldMapTimeoutToAiServiceTimeout() {
|
||||
service.stubException(new ResourceAccessException("read timeout",
|
||||
new SocketTimeoutException("Read timed out")));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_TIMEOUT.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-AI-SUM-11: HTTP 401 等错误应映射为 AI_SERVICE_UNAVAILABLE")
|
||||
void shouldMapHttpErrorToAiServiceUnavailable() {
|
||||
service.stubException(Mockito.mock(RestClientResponseException.class));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
private Map<String, Object> buildModelResponse(String summary) {
|
||||
return Map.of(
|
||||
"model", "qwen-turbo",
|
||||
"choices", List.of(
|
||||
Map.of("message", Map.of("role", "assistant", "content", summary))
|
||||
),
|
||||
"usage", Map.of(
|
||||
"prompt_tokens", 123,
|
||||
"completion_tokens", 45,
|
||||
"total_tokens", 168
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可观察的 service 子类,劫持 callModel 用于断言传入参数 / 抛出异常。
|
||||
*/
|
||||
private static class TestableAiSummaryService extends AiSummaryServiceImpl {
|
||||
private Map<String, Object> stubbedResponse;
|
||||
private RuntimeException stubbedException;
|
||||
private java.util.function.Consumer<Map<String, Object>> requestCapture;
|
||||
private int callCount = 0;
|
||||
|
||||
TestableAiSummaryService(DashScopeProperties properties, StringRedisTemplate redisTemplate) {
|
||||
super(properties, redisTemplate);
|
||||
}
|
||||
|
||||
void stubResponse(Map<String, Object> response) {
|
||||
this.stubbedResponse = response;
|
||||
}
|
||||
|
||||
void stubResponseWithCapture(Map<String, Object> response,
|
||||
java.util.function.Consumer<Map<String, Object>> capture) {
|
||||
this.stubbedResponse = response;
|
||||
this.requestCapture = capture;
|
||||
}
|
||||
|
||||
void stubException(RuntimeException ex) {
|
||||
this.stubbedException = ex;
|
||||
}
|
||||
|
||||
void assertNoCall() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(0, callCount, "不应调用模型");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, Object> callModel(Map<String, Object> requestBody) {
|
||||
callCount++;
|
||||
if (requestCapture != null) {
|
||||
requestCapture.accept(requestBody);
|
||||
}
|
||||
if (stubbedException != null) {
|
||||
throw stubbedException;
|
||||
}
|
||||
return stubbedResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import fun.nojava.module.blog.config.DashScopeProperties;
|
||||
import fun.nojava.module.blog.dto.GenerateSummaryDTO;
|
||||
import fun.nojava.module.blog.dto.SummaryResultVO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 真实调用阿里云百炼(DashScope)的联调测试。
|
||||
* <p>
|
||||
* 仅在环境变量 {@code DASHSCOPE_API_KEY} 存在时启用,避免在 CI 中无端消耗 token。
|
||||
* <p>
|
||||
* 本测试不操作数据库;Redis 限流计数器使用极大的 fake userId 隔离,结束时主动清理。
|
||||
*/
|
||||
@EnabledIfEnvironmentVariable(named = "DASHSCOPE_API_KEY", matches = ".+")
|
||||
class AiSummaryServiceLiveIT {
|
||||
|
||||
/** 远离任何真实用户 ID 的隔离 userId,确保限流键不会与生产数据冲突 */
|
||||
private static final long FAKE_USER_ID = 9_999_999_999L;
|
||||
|
||||
private DashScopeProperties properties;
|
||||
private StringRedisTemplate redisTemplate;
|
||||
private ValueOperations<String, String> valueOps;
|
||||
private AiSummaryServiceImpl service;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
String apiKey = System.getenv("DASHSCOPE_API_KEY");
|
||||
assertNotNull(apiKey, "DASHSCOPE_API_KEY 未设置");
|
||||
|
||||
properties = new DashScopeProperties();
|
||||
properties.setApiKey(apiKey);
|
||||
properties.setBaseUrl(envOrDefault("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"));
|
||||
properties.setModel(envOrDefault("DASHSCOPE_MODEL", "qwen-turbo"));
|
||||
properties.setTimeout(30000);
|
||||
properties.setMaxInput(8000);
|
||||
properties.setRateLimitPerMinute(50);
|
||||
|
||||
redisTemplate = mock(StringRedisTemplate.class);
|
||||
valueOps = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||
when(valueOps.increment(anyString())).thenReturn(1L);
|
||||
|
||||
service = new AiSummaryServiceImpl(properties, redisTemplate);
|
||||
service.init();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (redisTemplate != null) {
|
||||
try {
|
||||
redisTemplate.delete("ai_summary:rate:" + FAKE_USER_ID);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("live")
|
||||
@DisplayName("TC-AI-SUM-LIVE-01: 真实调用百炼 qwen-turbo 应返回非空摘要")
|
||||
void shouldGenerateRealSummary() {
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setTitle("Spring Boot 3 入门指南");
|
||||
dto.setContent("Spring Boot 是一个用于简化 Spring 应用开发的开源框架。"
|
||||
+ "它通过自动配置、起步依赖和内嵌服务器等特性,大幅降低了 Spring 项目的搭建成本。"
|
||||
+ "Spring Boot 3 基于 Spring Framework 6 和 Java 17,全面支持 GraalVM Native Image 与 Jakarta EE 9+ 命名空间,"
|
||||
+ "并对响应式编程、可观测性(Micrometer Observation API)做了进一步增强。"
|
||||
+ "本文将从环境搭建、自动配置原理、Starter 工程结构、Actuator 监控、配置中心集成等方面,"
|
||||
+ "系统介绍 Spring Boot 3 的核心特性与最佳实践,帮助读者快速上手并构建生产级应用。");
|
||||
|
||||
SummaryResultVO result = service.generateSummary(FAKE_USER_ID, dto);
|
||||
|
||||
assertNotNull(result, "返回结果不应为 null");
|
||||
assertNotNull(result.getSummary(), "summary 不应为 null");
|
||||
assertFalse(result.getSummary().isBlank(), "summary 不应为空白");
|
||||
assertTrue(result.getSummary().length() >= 30,
|
||||
"摘要应不少于 30 字符(实际:" + result.getSummary().length() + ")");
|
||||
assertTrue(result.getSummary().length() <= 200,
|
||||
"摘要应不超过 200 字符(实际:" + result.getSummary().length() + ")");
|
||||
assertNotNull(result.getModel(), "model 字段不应为 null");
|
||||
assertNotNull(result.getCostMillis(), "costMillis 不应为 null");
|
||||
|
||||
System.out.println("=== Live DashScope Test ===");
|
||||
System.out.println("model = " + result.getModel());
|
||||
System.out.println("promptTokens = " + result.getPromptTokens());
|
||||
System.out.println("completionTokens= " + result.getCompletionTokens());
|
||||
System.out.println("costMillis = " + result.getCostMillis());
|
||||
System.out.println("summary = " + result.getSummary());
|
||||
System.out.println("===========================");
|
||||
}
|
||||
|
||||
private String envOrDefault(String name, String defaultValue) {
|
||||
String value = System.getenv(name);
|
||||
return (value == null || value.isBlank()) ? defaultValue : value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user