diff --git a/doc/文章标签自动推荐功能技术实施文档.md b/doc/文章标签自动推荐功能技术实施文档.md new file mode 100644 index 0000000..0371264 --- /dev/null +++ b/doc/文章标签自动推荐功能技术实施文档.md @@ -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 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 response = callModel(requestBody); + +// 3. 解析响应(解析逗号分隔的标签) +List tags = parseTagsFromResponse(response); +``` + +### 4.2 前端核心逻辑 + +```vue + + + + + + + + 智能推荐 + + + + +
+ + + + 确认添加 +
+``` + +--- + +## 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 机制,避免额外开发 diff --git a/jog-admin/pom.xml b/jog-admin/pom.xml index 6b865ab..899ac79 100644 --- a/jog-admin/pom.xml +++ b/jog-admin/pom.xml @@ -52,6 +52,10 @@ lombok true + + org.springframework.boot + spring-boot-starter-validation + org.springframework.boot spring-boot-starter-test @@ -67,6 +71,7 @@ frontend-maven-plugin 1.15.1 + ${maven.frontend.skip} ${project.basedir}/../jog-frontend v24.15.0 11.12.1 diff --git a/jog-framework/src/main/java/fun/nojava/framework/handler/GlobalExceptionHandler.java b/jog-framework/src/main/java/fun/nojava/framework/handler/GlobalExceptionHandler.java index 406d12b..186c67f 100644 --- a/jog-framework/src/main/java/fun/nojava/framework/handler/GlobalExceptionHandler.java +++ b/jog-framework/src/main/java/fun/nojava/framework/handler/GlobalExceptionHandler.java @@ -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 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 handleNoResourceFound(NoResourceFoundException e) { + return ApiResult.error(ErrorCode.NOT_FOUND); + } + @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResult handleException(Exception e) { diff --git a/jog-frontend/src/api/article.js b/jog-frontend/src/api/article.js index de13012..9e4ee37 100644 --- a/jog-frontend/src/api/article.js +++ b/jog-frontend/src/api/article.js @@ -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 }) } diff --git a/jog-frontend/src/components.d.ts b/jog-frontend/src/components.d.ts index 6ae8a33..63a9e29 100644 --- a/jog-frontend/src/components.d.ts +++ b/jog-frontend/src/components.d.ts @@ -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'] diff --git a/jog-frontend/src/views/app/ArticleEditor.vue b/jog-frontend/src/views/app/ArticleEditor.vue index b5a84d9..588925a 100644 --- a/jog-frontend/src/views/app/ArticleEditor.vue +++ b/jog-frontend/src/views/app/ArticleEditor.vue @@ -11,9 +11,32 @@ - - - +
+
+ + + + + 智能推荐 + +
+
+ 推荐标签: + + + + + 确认添加 + + 取消 +
+
@@ -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; +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/AppTagController.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/AppTagController.java new file mode 100644 index 0000000..1eca130 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/AppTagController.java @@ -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 createTag(@RequestParam String name) { + Tag existing = tagMapper.selectOne( + new LambdaQueryWrapper().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); + } +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java index ec9939f..66baa39 100644 --- a/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java @@ -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 recommendTags( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody RecommendTagsDTO dto) { + return ApiResult.success(tagRecommendService.recommendTags(userId, dto)); + } + @Operation(summary = "更新文章") - @PutMapping("/{id}") + @PutMapping("/" + ARTICLE_ID) public ApiResult 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 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 restoreArticle( @AuthenticationPrincipal Long userId, @PathVariable Long id) { @@ -68,7 +81,7 @@ public class ArticleController { } @Operation(summary = "删除文章") - @DeleteMapping("/{id}") + @DeleteMapping("/" + ARTICLE_ID) public ApiResult 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 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)); } + } diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/RecommendTagsDTO.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/RecommendTagsDTO.java new file mode 100644 index 0000000..49449c4 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/RecommendTagsDTO.java @@ -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; +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/TagRecommendResultVO.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/TagRecommendResultVO.java new file mode 100644 index 0000000..1001890 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/TagRecommendResultVO.java @@ -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 tags; + + private String model; + + private Integer promptTokens; + + private Integer completionTokens; + + private Long costMillis; +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/service/TagRecommendService.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/TagRecommendService.java new file mode 100644 index 0000000..3a2449d --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/TagRecommendService.java @@ -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); +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/TagRecommendServiceImpl.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/TagRecommendServiceImpl.java new file mode 100644 index 0000000..7d9cdfe --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/TagRecommendServiceImpl.java @@ -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 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 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 response, long costMillis) { + if (response == null) { + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + try { + List> choices = (List>) response.get("choices"); + if (choices == null || choices.isEmpty()) { + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + Map message = (Map) 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 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 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)]*>.*?", " ") + .replaceAll("(?is)]*>.*?", " ") + .replaceAll("<[^>]+>", " ") + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\""); + return text.replaceAll("\\s+", " ").trim(); + } +} diff --git a/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TagRecommendServiceImplTest.java b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TagRecommendServiceImplTest.java new file mode 100644 index 0000000..87c3f6e --- /dev/null +++ b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TagRecommendServiceImplTest.java @@ -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 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("

这是一段 HTML 富文本,足够长以通过校验。

"); + + service.recommendTags(1L, dto); + + verify(mockAiSummaryService).callModel(argThat(requestBody -> { + @SuppressWarnings("unchecked") + List> messages = (List>) requestBody.get("messages"); + String userContent = (String) messages.get(1).get("content"); + assertFalse(userContent.contains("

"), "HTML 标签未被剥离: " + userContent); + assertFalse(userContent.contains(""), "HTML 标签未被剥离: " + userContent); + assertTrue(userContent.contains("HTML"), "纯文本内容缺失"); + return true; + })); + } + + @Test + @Tag("p0") + @DisplayName("TC-TAG-REC-09: choices 为空应映射为 AI_SERVICE_UNAVAILABLE") + void shouldFailWhenChoicesEmpty() { + Map 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> messages = (List>) 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 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 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 + ) + ); + } +} diff --git a/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TagRecommendServiceLiveIT.java b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TagRecommendServiceLiveIT.java new file mode 100644 index 0000000..c98deab --- /dev/null +++ b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TagRecommendServiceLiveIT.java @@ -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)的标签推荐联调测试。 + *

+ * 仅在环境变量 {@code DASHSCOPE_API_KEY} 存在时启用,避免在 CI 中无端消耗 token。 + *

+ * 本测试不操作数据库;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 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; + } +} diff --git a/jog-module-system/src/main/java/fun/nojava/module/system/dto/LoginDTO.java b/jog-module-system/src/main/java/fun/nojava/module/system/dto/LoginDTO.java index fac1d24..3824fc9 100644 --- a/jog-module-system/src/main/java/fun/nojava/module/system/dto/LoginDTO.java +++ b/jog-module-system/src/main/java/fun/nojava/module/system/dto/LoginDTO.java @@ -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 = "密码不能为空") diff --git a/pom.xml b/pom.xml index 618c195..51e2c89 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ + false 21 21 21