集成AI摘要

This commit is contained in:
nojava
2026-05-21 16:01:34 +08:00
parent 1bce12927e
commit 14c3f98449
14 changed files with 1035 additions and 6 deletions
@@ -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;
}
@@ -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);
}
@@ -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("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"");
return text.replaceAll("\\s+", " ").trim();
}
}
@@ -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;
}
}
}
@@ -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;
}
}