添加自动推荐标签功能

This commit is contained in:
nojava
2026-05-23 03:17:46 +08:00
parent 6e145d0980
commit 20b7a57fb9
16 changed files with 1182 additions and 9 deletions
@@ -0,0 +1,33 @@
package fun.nojava.module.blog.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import fun.nojava.common.model.ApiResult;
import fun.nojava.module.blog.entity.Tag;
import fun.nojava.module.blog.mapper.TagMapper;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@io.swagger.v3.oas.annotations.tags.Tag(name = "用户标签接口")
@RestController
@RequestMapping("/api/app/tag")
@RequiredArgsConstructor
public class AppTagController {
private final TagMapper tagMapper;
@Operation(summary = "创建标签(如已存在则返回已有标签)")
@PostMapping
public ApiResult<Tag> createTag(@RequestParam String name) {
Tag existing = tagMapper.selectOne(
new LambdaQueryWrapper<Tag>().eq(Tag::getName, name)
);
if (existing != null) {
return ApiResult.success(existing);
}
Tag tag = new Tag();
tag.setName(name);
tagMapper.insert(tag);
return ApiResult.success(tag);
}
}
@@ -5,6 +5,7 @@ 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 fun.nojava.module.blog.service.TagRecommendService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -18,8 +19,12 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class ArticleController {
/** 仅匹配数字 ID,避免 /recommend-tags 等字面路径被当作 {id} */
private static final String ARTICLE_ID = "{id:\\d+}";
private final ArticleService articleService;
private final AiSummaryService aiSummaryService;
private final TagRecommendService tagRecommendService;
@Operation(summary = "创建文章")
@PostMapping
@@ -29,8 +34,16 @@ public class ArticleController {
return ApiResult.success(articleService.createArticle(userId, dto));
}
@Operation(summary = "AI 推荐文章标签")
@PostMapping("/recommend-tags")
public ApiResult<TagRecommendResultVO> recommendTags(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody RecommendTagsDTO dto) {
return ApiResult.success(tagRecommendService.recommendTags(userId, dto));
}
@Operation(summary = "更新文章")
@PutMapping("/{id}")
@PutMapping("/" + ARTICLE_ID)
public ApiResult<Void> updateArticle(
@AuthenticationPrincipal Long userId,
@PathVariable Long id,
@@ -50,7 +63,7 @@ public class ArticleController {
}
@Operation(summary = "移至回收站")
@PutMapping("/{id}/recycle")
@PutMapping("/" + ARTICLE_ID + "/recycle")
public ApiResult<Void> moveToRecycleBin(
@AuthenticationPrincipal Long userId,
@PathVariable Long id) {
@@ -59,7 +72,7 @@ public class ArticleController {
}
@Operation(summary = "从回收站恢复")
@PutMapping("/{id}/restore")
@PutMapping("/" + ARTICLE_ID + "/restore")
public ApiResult<Void> restoreArticle(
@AuthenticationPrincipal Long userId,
@PathVariable Long id) {
@@ -68,7 +81,7 @@ public class ArticleController {
}
@Operation(summary = "删除文章")
@DeleteMapping("/{id}")
@DeleteMapping("/" + ARTICLE_ID)
public ApiResult<Void> deleteArticle(
@AuthenticationPrincipal Long userId,
@PathVariable Long id) {
@@ -77,7 +90,7 @@ public class ArticleController {
}
@Operation(summary = "点赞文章")
@PostMapping("/{id}/like")
@PostMapping("/" + ARTICLE_ID + "/like")
public ApiResult<Boolean> toggleLike(
@PathVariable Long id,
@AuthenticationPrincipal Long userId) {
@@ -91,4 +104,5 @@ public class ArticleController {
@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 RecommendTagsDTO {
@Size(max = 200, message = "标题最长200字符")
private String title;
@NotBlank(message = "内容不能为空")
@Size(max = 50000, message = "正文过长")
private String content;
}
@@ -0,0 +1,21 @@
package fun.nojava.module.blog.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class TagRecommendResultVO {
private List<String> tags;
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.RecommendTagsDTO;
import fun.nojava.module.blog.dto.TagRecommendResultVO;
public interface TagRecommendService {
TagRecommendResultVO recommendTags(Long userId, RecommendTagsDTO dto);
}
@@ -0,0 +1,192 @@
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.RecommendTagsDTO;
import fun.nojava.module.blog.dto.TagRecommendResultVO;
import fun.nojava.module.blog.service.TagRecommendService;
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.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class TagRecommendServiceImpl implements TagRecommendService {
private static final int MIN_CONTENT_LENGTH = 10;
private static final int MAX_TAGS = 5;
private static final String RATE_LIMIT_KEY_PREFIX = "tag_recommend:rate:";
private static final String SYSTEM_PROMPT =
"你是一名专业的博客编辑,请根据用户提供的标题与正文," +
"提取 3-5 个最合适的标签。只返回标签词,用逗号分隔,不要有其他内容。";
private final AiSummaryServiceImpl aiSummaryService;
private final DashScopeProperties properties;
private final StringRedisTemplate redisTemplate;
@Override
public TagRecommendResultVO recommendTags(Long userId, RecommendTagsDTO 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.5
);
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;
TagRecommendResultVO result = parseResponse(response, costMillis);
log.info("标签推荐成功 userId={}, model={}, tags={}, cost={}ms",
userId, result.getModel(), result.getTags(), costMillis);
return result;
}
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 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();
}
@SuppressWarnings("unchecked")
private TagRecommendResultVO parseResponse(Map<String, Object> response, long costMillis) {
if (response == null) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
try {
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);
}
List<String> tags = parseTags(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 TagRecommendResultVO.builder()
.tags(tags)
.model(model)
.promptTokens(promptTokens)
.completionTokens(completionTokens)
.costMillis(costMillis)
.build();
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
log.error("解析标签推荐响应失败 error={}", ex.getMessage(), ex);
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
}
private List<String> parseTags(String raw) {
return Arrays.stream(raw.split("[,]"))
.map(String::trim)
.filter(tag -> !tag.isEmpty())
.limit(MAX_TAGS)
.collect(Collectors.toList());
}
private Integer toInt(Object value) {
if (value instanceof Number n) {
return n.intValue();
}
return null;
}
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,384 @@
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.RecommendTagsDTO;
import fun.nojava.module.blog.dto.TagRecommendResultVO;
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 TagRecommendServiceImplTest {
private DashScopeProperties properties;
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOps;
private AiSummaryServiceImpl mockAiSummaryService;
private TagRecommendServiceImpl 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);
mockAiSummaryService = mock(AiSummaryServiceImpl.class);
service = new TagRecommendServiceImpl(mockAiSummaryService, properties, redisTemplate);
}
// ==================== P0 测试 ====================
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-01: 正文过短(<10字符)应抛出 AI_CONTENT_TOO_SHORT")
void shouldRejectWhenContentTooShort() {
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle("标题");
dto.setContent("太短了");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_CONTENT_TOO_SHORT.getCode(), ex.getCode());
verify(valueOps, never()).increment(anyString());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-02: 未配置 API Key 应抛出 AI_API_KEY_MISSING")
void shouldRejectWhenApiKeyMissing() {
properties.setApiKey("");
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_API_KEY_MISSING.getCode(), ex.getCode());
verify(valueOps, never()).increment(anyString());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-03: 单用户超过限流应触发 RATE_LIMITED")
void shouldThrowRateLimitedWhenExceedsLimit() {
when(valueOps.increment("tag_recommend:rate:7")).thenReturn(6L);
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(7L, dto));
assertEquals(ErrorCode.RATE_LIMITED.getCode(), ex.getCode());
verify(mockAiSummaryService, never()).callModel(any());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-04: 正常返回应填充 tags、model、tokens、耗时")
void shouldReturnTagsOnSuccess() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring Boot, 微服务"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle("Spring Cloud 微服务实践");
dto.setContent("这是一篇关于微服务架构的详细技术文章,涵盖服务注册与发现、配置中心等内容。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertNotNull(result.getTags());
assertEquals(3, result.getTags().size());
assertEquals("Java", result.getTags().get(0));
assertEquals("Spring Boot", result.getTags().get(1));
assertEquals("微服务", result.getTags().get(2));
assertEquals("qwen-turbo", result.getModel());
assertEquals(123, result.getPromptTokens());
assertEquals(45, result.getCompletionTokens());
assertNotNull(result.getCostMillis());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-05: 标签数量不应超过 MAX_TAGS(5个)")
void shouldLimitTagsToMax() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring, 微服务, Docker, K8s, 多余标签, 更多"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals(5, result.getTags().size());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-06: 支持中英文逗号混合分隔")
void shouldParseCommaAndChineseComma() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("JavaSpring Boot,微服务"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals(3, result.getTags().size());
assertEquals("Java", result.getTags().get(0));
assertEquals("Spring Boot", result.getTags().get(1));
assertEquals("微服务", result.getTags().get(2));
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-07: 空白标签应被过滤")
void shouldFilterEmptyTags() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, , Spring, , 微服务"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals(3, result.getTags().size());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-08: 正文中的 HTML 标签应被剥离")
void shouldStripHtmlBeforeCallingModel() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring Boot"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("<p>这是一段 <strong>HTML</strong> 富文本,足够长以通过校验。</p>");
service.recommendTags(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");
assertFalse(userContent.contains("<p>"), "HTML 标签未被剥离: " + userContent);
assertFalse(userContent.contains("<strong>"), "HTML 标签未被剥离: " + userContent);
assertTrue(userContent.contains("HTML"), "纯文本内容缺失");
return true;
}));
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-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");
when(mockAiSummaryService.callModel(any())).thenReturn(response);
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-10: 模型调用异常应映射为 AI_SERVICE_UNAVAILABLE")
void shouldMapExceptionToAiServiceUnavailable() {
when(mockAiSummaryService.callModel(any()))
.thenThrow(new RuntimeException("网络异常"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
}
// ==================== P1 测试 ====================
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-11: 标题为 null 时应正常调用模型")
void shouldWorkWhenTitleIsNull() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("标签A, 标签B"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle(null);
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertNotNull(result.getTags());
assertEquals(2, result.getTags().size());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-12: 长正文应截断到 maxInput 上限")
void shouldTruncateLongContentToMaxInput() {
properties.setMaxInput(50);
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("X".repeat(500));
service.recommendTags(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");
// 正文部分应该被截断到 50 个字符
long xCount = userContent.chars().filter(c -> c == 'X').count();
assertEquals(50, xCount, "正文应截断到 maxInput 字符");
return true;
}));
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-13: 首次调用应设置限流 key 的 1 分钟 TTL")
void shouldExpireRateLimitKeyOnFirstCall() {
when(valueOps.increment("tag_recommend:rate:8")).thenReturn(1L);
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
service.recommendTags(8L, dto);
verify(redisTemplate, times(1)).expire(eq("tag_recommend:rate:8"), eq(1L), any());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-14: userId 为 null 时不应限流,应正常调用模型")
void shouldNotRateLimitWhenUserIdNull() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(null, dto);
assertNotNull(result.getTags());
assertEquals(2, result.getTags().size());
verify(mockAiSummaryService).callModel(any());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-15: 模型返回 content 为 blank 应映射为 AI_SERVICE_UNAVAILABLE")
void shouldFailWhenContentIsBlank() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse(" "));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-16: 响应中无 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", "Java, Spring"))
)
);
when(mockAiSummaryService.callModel(any())).thenReturn(response);
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertNull(result.getPromptTokens());
assertNull(result.getCompletionTokens());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-17: 模型返回单个标签应正常解析")
void shouldWorkWithSingleTag() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("全栈开发"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals(1, result.getTags().size());
assertEquals("全栈开发", result.getTags().get(0));
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-18: 模型返回前后带空格的标签应去除空格")
void shouldTrimTagWhitespace() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse(" Java , Spring Boot , 微服务 "));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals("Java", result.getTags().get(0));
assertEquals("Spring Boot", result.getTags().get(1));
assertEquals("微服务", result.getTags().get(2));
}
// ==================== 辅助方法 ====================
private Map<String, Object> buildModelResponse(String tagsContent) {
return Map.of(
"model", "qwen-turbo",
"choices", List.of(
Map.of("message", Map.of("role", "assistant", "content", tagsContent))
),
"usage", Map.of(
"prompt_tokens", 123,
"completion_tokens", 45,
"total_tokens", 168
)
);
}
}
@@ -0,0 +1,137 @@
package fun.nojava.module.blog.service.impl;
import fun.nojava.module.blog.config.DashScopeProperties;
import fun.nojava.module.blog.dto.RecommendTagsDTO;
import fun.nojava.module.blog.dto.TagRecommendResultVO;
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 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 TagRecommendServiceLiveIT {
/** 远离任何真实用户 ID 的隔离 userId */
private static final long FAKE_USER_ID = 9_999_999_998L;
private DashScopeProperties properties;
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOps;
private AiSummaryServiceImpl aiSummaryService;
private TagRecommendServiceImpl 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);
aiSummaryService = new AiSummaryServiceImpl(properties, redisTemplate);
aiSummaryService.init();
service = new TagRecommendServiceImpl(aiSummaryService, properties, redisTemplate);
}
@AfterEach
void tearDown() {
if (redisTemplate != null) {
try {
redisTemplate.delete("tag_recommend:rate:" + FAKE_USER_ID);
} catch (Exception ignored) {
}
}
}
@Test
@Tag("live")
@DisplayName("TC-TAG-REC-LIVE-01: 真实调用百炼应返回 3-5 个有效标签")
void shouldRecommendRealTags() {
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle("Spring Boot 微服务实战");
dto.setContent("Spring Boot 是一个用于简化 Spring 应用开发的开源框架。"
+ "它通过自动配置、起步依赖和内嵌服务器等特性,大幅降低了 Spring 项目的搭建成本。"
+ "在微服务架构中,Spring Boot 结合 Spring Cloud 提供了服务注册与发现(Eureka/Nacos)、"
+ "配置中心、API 网关(Gateway)、负载均衡、熔断降级(Resilience4j)等全套解决方案。"
+ "本文从服务拆分原则、Docker 容器化、Kubernetes 部署等方面,"
+ "系统介绍如何基于 Spring Boot 构建生产级微服务体系。");
TagRecommendResultVO result = service.recommendTags(FAKE_USER_ID, dto);
assertNotNull(result, "返回结果不应为 null");
assertNotNull(result.getTags(), "tags 列表不应为 null");
assertFalse(result.getTags().isEmpty(), "tags 列表不应为空");
assertTrue(result.getTags().size() >= 1, "至少应返回 1 个标签");
assertTrue(result.getTags().size() <= 5, "标签数量不应超过 5 个");
// 每个标签不应为空
for (String tag : result.getTags()) {
assertNotNull(tag, "单个标签不应为 null");
assertFalse(tag.isBlank(), "单个标签不应为空白");
}
assertNotNull(result.getModel(), "model 字段不应为 null");
assertNotNull(result.getCostMillis(), "costMillis 不应为 null");
System.out.println("=== Live DashScope Tag Recommend 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("tags = " + result.getTags());
System.out.println("========================================");
}
@Test
@Tag("live")
@DisplayName("TC-TAG-REC-LIVE-02: 真实调用应返回逗号可分隔的标签文本")
void shouldReturnCommaSeparableTags() {
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle("Java 并发编程");
dto.setContent("Java 并发编程是高级开发者的必备技能。本文深入探讨了 Java 内存模型(JMM)、"
+ "synchronized 与 ReentrantLock 的底层实现、CAS 无锁算法、线程池 ThreadPoolExecutor "
+ "的七大参数与拒绝策略、以及 CompletableFuture 异步编程模型。"
+ "同时结合实际生产案例,分析死锁诊断、性能优化、JUC 工具类选型等常见问题与解决方案。");
TagRecommendResultVO result = service.recommendTags(FAKE_USER_ID, dto);
assertNotNull(result.getTags());
assertTrue(result.getTags().size() >= 1);
System.out.println("=== Live Tag Recommend Test 2 ===");
System.out.println("tags = " + result.getTags());
System.out.println("==================================");
}
private String envOrDefault(String name, String defaultValue) {
String value = System.getenv(name);
return (value == null || value.isBlank()) ? defaultValue : value;
}
}