添加自动推荐标签功能
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
-5
@@ -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);
|
||||
}
|
||||
+192
@@ -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(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"");
|
||||
return text.replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
}
|
||||
+384
@@ -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("Java,Spring 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+137
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user