添加自动推荐标签功能
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
# 文章标签自动推荐功能技术实施文档
|
||||
|
||||
## 前提条件
|
||||
|
||||
**数据安全规则**:
|
||||
- 联调和测试过程中如需创建测试数据,测试完成后必须删除
|
||||
- **数据库中存在一篇名为 "hello" 的文章,此文章不允许任何修改或删除操作**
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能概述
|
||||
|
||||
基于 AI 模型根据文章内容自动推荐 3-5 个合适标签,博主可一键确认添加。
|
||||
|
||||
## 2. 核心实现思路
|
||||
|
||||
### 2.1 复用现有 AI 能力
|
||||
|
||||
项目已有 `AiSummaryService` 调用阿里云 DashScope 模型的能力,标签推荐功能将**完全复用**该服务:
|
||||
|
||||
- 复用 `DashScopeProperties` 配置(API Key、超时、限流等)
|
||||
- 复用模型调用逻辑(HTTP 请求、响应解析、错误处理)
|
||||
- 复用 Rate Limit 限流机制
|
||||
|
||||
### 2.2 差异化实现
|
||||
|
||||
与摘要生成的主要差异:
|
||||
|
||||
| 特性 | 摘要生成 | 标签推荐 |
|
||||
|------|----------|----------|
|
||||
| System Prompt | 生成摘要的专业提示词 | 提取标签的专业提示词 |
|
||||
| User Prompt | 标题 + 正文 | 标题 + 正文(需要提取标签) |
|
||||
| 响应解析 | 截取摘要文本 | 解析逗号分隔的标签列表 |
|
||||
| 返回格式 | SummaryResultVO | 推荐标签列表 |
|
||||
|
||||
### 2.3 实现方案
|
||||
|
||||
**后端**:新建 `TagRecommendService` 服务类,复用 `AiSummaryServiceImpl` 的模型调用能力
|
||||
|
||||
**前端**:在文章编辑页标签区域添加推荐按钮,展示推荐标签并支持一键添加
|
||||
|
||||
---
|
||||
|
||||
## 3. 改动项清单
|
||||
|
||||
### 3.1 后端改动
|
||||
|
||||
#### 3.1.1 新增 DTO
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dto/RecommendTagsDTO.java` | 请求 DTO,包含 title、content 字段 |
|
||||
|
||||
#### 3.1.2 新增 VO
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dto/TagRecommendResultVO.java` | 返回 VO,包含 tags 列表、model、costMillis 等 |
|
||||
|
||||
#### 3.1.3 新增服务接口
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `service/TagRecommendService.java` | 标签推荐服务接口 |
|
||||
|
||||
#### 3.1.4 新增服务实现
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `service/impl/TagRecommendServiceImpl.java` | 复用 AiSummaryServiceImpl 的 callModel 方法,实现标签推荐逻辑 |
|
||||
|
||||
#### 3.1.5 新增 Controller 接口
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `controller/ArticleController.java` | 新增 `/recommend-tags` POST 接口 |
|
||||
|
||||
#### 3.1.6 错误码(如需要)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `common/exception/ErrorCode.java` | 可能需要新增错误码(如 AI_CONTENT_TOO_SHORT 已存在可复用) |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 前端改动
|
||||
|
||||
#### 3.2.1 新增 API 方法
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/api/article.js` | 新增 `recommendTags(data)` 方法 |
|
||||
|
||||
#### 3.2.2 修改文章编辑器
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/views/app/ArticleEditor.vue` | 在标签区域添加"智能推荐"按钮及交互逻辑 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心逻辑说明
|
||||
|
||||
### 4.1 后端核心逻辑
|
||||
|
||||
```java
|
||||
// TagRecommendServiceImpl.java
|
||||
|
||||
// 专用提示词
|
||||
private static final String SYSTEM_PROMPT =
|
||||
"你是一名专业的博客编辑,请根据用户提供的标题与正文," +
|
||||
"提取 3-5 个最合适的标签。只返回标签词,用逗号分隔,不要有其他内容。";
|
||||
|
||||
private static final int TAG_RECOMMEND_MIN_LENGTH = 10;
|
||||
|
||||
// 1. 构建请求(复用 AiSummaryServiceImpl.callModel)
|
||||
Map<String, Object> requestBody = Map.of(
|
||||
"model", properties.getModel(),
|
||||
"messages", List.of(
|
||||
Map.of("role", "system", "content", SYSTEM_PROMPT),
|
||||
Map.of("role", "user", "content", buildUserPrompt(dto.getTitle(), plainContent))
|
||||
),
|
||||
"temperature", 0.5 // 标签推荐可以稍高一些,增加多样性
|
||||
);
|
||||
|
||||
// 2. 调用模型(复用 AiSummaryServiceImpl.callModel)
|
||||
Map<String, Object> response = callModel(requestBody);
|
||||
|
||||
// 3. 解析响应(解析逗号分隔的标签)
|
||||
List<String> tags = parseTagsFromResponse(response);
|
||||
```
|
||||
|
||||
### 4.2 前端核心逻辑
|
||||
|
||||
```vue
|
||||
<!-- ArticleEditor.vue 标签区域 -->
|
||||
<el-form-item label="标签">
|
||||
<el-select v-model="form.tagIds" multiple ...>
|
||||
<el-option v-for="tag in tags" ... />
|
||||
</el-select>
|
||||
<!-- 新增推荐按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="recommending"
|
||||
@click="handleRecommendTags"
|
||||
>
|
||||
智能推荐
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 推荐结果展示 -->
|
||||
<div v-if="recommendedTags.length" class="tag-recommend-result">
|
||||
<el-checkbox-group v-model="selectedRecommendedTags">
|
||||
<el-checkbox
|
||||
v-for="tag in recommendedTags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</el-checkbox-group>
|
||||
<el-button @click="confirmAddTags">确认添加</el-button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 待办事项
|
||||
|
||||
| 序号 | 待办项 | 状态 |
|
||||
|------|--------|------|
|
||||
| 1 | 后端:创建 RecommendTagsDTO.java | ✅ 已完成 |
|
||||
| 2 | 后端:创建 TagRecommendResultVO.java | ✅ 已完成 |
|
||||
| 3 | 后端:创建 TagRecommendService 接口 | ✅ 已完成 |
|
||||
| 4 | 后端:创建 TagRecommendServiceImpl 实现类 | ✅ 已完成 |
|
||||
| 5 | 后端:在 ArticleController 添加 /recommend-tags 接口 | ✅ 已完成 |
|
||||
| 6 | 前端:在 article.js 添加 recommendTags API 方法 | ✅ 已完成 |
|
||||
| 7 | 前端:在 ArticleEditor.vue 添加智能推荐按钮和交互 | ✅ 已完成 |
|
||||
| 8 | 后端:单元测试(18 个用例,P0+P1) | ✅ 已完成 |
|
||||
| 9 | 前后端联调测试(LiveIT 真实 AI 调用验证) | ✅ 已完成 |
|
||||
| 10 | 功能验收测试(API + 前端静态资源验证) | ✅ 已完成 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施顺序建议
|
||||
|
||||
1. **先做后端**:DTO → VO → Service → Controller
|
||||
2. **再做前端**:API 方法 → 页面交互
|
||||
3. **最后联调**:前后端对接、bug 修复、验收
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与注意事项
|
||||
|
||||
1. **模型响应格式**:需处理模型返回格式不稳定的情况(如多余空格、换行)
|
||||
2. **标签去重**:推荐标签可能与已有标签重复,需前端过滤
|
||||
3. **内容过短**:正文少于 10 字符时不调用 AI,直接提示用户
|
||||
4. **限流复用**:复用现有 Rate Limit 机制,避免额外开发
|
||||
@@ -52,6 +52,10 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@@ -67,6 +71,7 @@
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>1.15.1</version>
|
||||
<configuration>
|
||||
<skip>${maven.frontend.skip}</skip>
|
||||
<workingDirectory>${project.basedir}/../jog-frontend</workingDirectory>
|
||||
<nodeVersion>v24.15.0</nodeVersion>
|
||||
<npmVersion>11.12.1</npmVersion>
|
||||
|
||||
@@ -7,10 +7,12 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -41,6 +43,19 @@ public class GlobalExceptionHandler {
|
||||
return ApiResult.error(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
public ApiResult<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
|
||||
log.warn("Method not supported: {}", e.getMessage());
|
||||
return ApiResult.error(ErrorCode.METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public ApiResult<Void> handleNoResourceFound(NoResourceFoundException e) {
|
||||
return ApiResult.error(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public ApiResult<Void> handleException(Exception e) {
|
||||
|
||||
@@ -40,6 +40,10 @@ export function generateArticleSummary(data) {
|
||||
return request.post('/api/app/article/summary', data, { timeout: 35000 })
|
||||
}
|
||||
|
||||
export function recommendTags(data) {
|
||||
return request.post('/api/app/article/recommend-tags', data, { timeout: 35000 })
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return request.get('/api/public/category/list')
|
||||
}
|
||||
@@ -64,6 +68,10 @@ export function createTag(name) {
|
||||
return request.post('/api/admin/tag', null, { params: { name } })
|
||||
}
|
||||
|
||||
export function createAppTag(name) {
|
||||
return request.post('/api/app/tag', null, { params: { name } })
|
||||
}
|
||||
|
||||
export function getAdminArticles(params) {
|
||||
return request.get('/api/admin/article/list', { params })
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -13,6 +13,7 @@ declare module 'vue' {
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
|
||||
@@ -11,9 +11,32 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select v-model="form.tagIds" multiple placeholder="选择标签(最多5个)" :max-collapse-tags="5">
|
||||
<div class="tag-wrapper">
|
||||
<div class="tag-select-row">
|
||||
<el-select v-model="form.tagIds" multiple placeholder="选择标签(最多5个)" :max-collapse-tags="5" class="tag-select">
|
||||
<el-option v-for="tag in tags" :key="tag.id" :label="tag.name" :value="tag.id" />
|
||||
</el-select>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="recommending"
|
||||
@click="handleRecommendTags"
|
||||
class="recommend-btn"
|
||||
>
|
||||
智能推荐
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="recommendedTags.length" class="tag-recommend-result">
|
||||
<span class="recommend-label">推荐标签:</span>
|
||||
<el-checkbox-group v-model="selectedRecommendedTags" class="recommend-checkboxes">
|
||||
<el-checkbox v-for="tag in recommendedTags" :key="tag" :label="tag" />
|
||||
</el-checkbox-group>
|
||||
<el-button type="success" size="small" @click="confirmAddTags" :disabled="!selectedRecommendedTags.length">
|
||||
确认添加
|
||||
</el-button>
|
||||
<el-button size="small" @click="clearRecommendedTags">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<div class="editor-wrapper">
|
||||
@@ -70,7 +93,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { createArticle, updateArticle, getArticleDetail, getCategories, getTags, generateArticleSummary } from '@/api/article'
|
||||
import { createArticle, updateArticle, getArticleDetail, getCategories, getTags, generateArticleSummary, recommendTags, createAppTag } from '@/api/article'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -79,6 +102,9 @@ const isEdit = computed(() => !!route.params.id)
|
||||
const saving = ref(false)
|
||||
const summarizing = ref(false)
|
||||
const lastSummaryMeta = ref('')
|
||||
const recommending = ref(false)
|
||||
const recommendedTags = ref([])
|
||||
const selectedRecommendedTags = ref([])
|
||||
const categories = ref([])
|
||||
const tags = ref([])
|
||||
const form = ref({
|
||||
@@ -137,6 +163,83 @@ async function handleGenerateSummary() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRecommendTags() {
|
||||
const plain = getPlainTextContent()
|
||||
if (plain.length < 10) {
|
||||
ElMessage.warning('正文过短,无法推荐标签')
|
||||
return
|
||||
}
|
||||
recommending.value = true
|
||||
recommendedTags.value = []
|
||||
selectedRecommendedTags.value = []
|
||||
try {
|
||||
const res = await recommendTags({
|
||||
title: form.value.title || '',
|
||||
content: plain,
|
||||
})
|
||||
const allTags = res.data.tags || []
|
||||
// 过滤已选中的标签
|
||||
const existingNames = tags.value
|
||||
.filter(t => form.value.tagIds.includes(t.id))
|
||||
.map(t => t.name)
|
||||
recommendedTags.value = allTags.filter(t => !existingNames.includes(t))
|
||||
if (!recommendedTags.value.length) {
|
||||
ElMessage.info('没有新的推荐标签')
|
||||
} else {
|
||||
ElMessage.success(`推荐了 ${recommendedTags.value.length} 个标签`)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.code === 'ECONNABORTED') {
|
||||
ElMessage.error('AI 服务响应超时,请稍后再试')
|
||||
}
|
||||
} finally {
|
||||
recommending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTagId(tagName) {
|
||||
const name = (tagName || '').trim()
|
||||
if (!name) return null
|
||||
const existing = tags.value.find(t => t.name === name)
|
||||
if (existing) return existing.id
|
||||
try {
|
||||
const res = await createAppTag(name)
|
||||
return res.data?.id ?? null
|
||||
} catch {
|
||||
ElMessage.warning(`标签「${name}」创建失败,已跳过`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAddTags() {
|
||||
if (!selectedRecommendedTags.value.length) return
|
||||
const idsToAdd = []
|
||||
for (const tagName of selectedRecommendedTags.value) {
|
||||
const tagId = await resolveTagId(tagName)
|
||||
if (tagId != null) idsToAdd.push(tagId)
|
||||
}
|
||||
if (!idsToAdd.length) return
|
||||
await loadTags()
|
||||
const merged = [...form.value.tagIds]
|
||||
for (const tagId of idsToAdd) {
|
||||
if (merged.includes(tagId)) continue
|
||||
if (merged.length >= 5) {
|
||||
ElMessage.warning('最多选择 5 个标签')
|
||||
break
|
||||
}
|
||||
merged.push(tagId)
|
||||
}
|
||||
form.value.tagIds = merged
|
||||
recommendedTags.value = []
|
||||
selectedRecommendedTags.value = []
|
||||
ElMessage.success('标签添加完成')
|
||||
}
|
||||
|
||||
function clearRecommendedTags() {
|
||||
recommendedTags.value = []
|
||||
selectedRecommendedTags.value = []
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
@@ -270,4 +373,39 @@ onBeforeUnmount(() => {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tag-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
.tag-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.tag-select {
|
||||
flex: 1;
|
||||
}
|
||||
.recommend-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-recommend-result {
|
||||
margin-top: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.recommend-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.recommend-checkboxes {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package fun.nojava.module.system.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
@@ -9,6 +10,7 @@ import lombok.Data;
|
||||
public class LoginDTO {
|
||||
|
||||
@NotBlank(message = "账号不能为空")
|
||||
@JsonAlias({"username", "phone", "account"})
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
|
||||
Reference in New Issue
Block a user