添加自动推荐标签功能

This commit is contained in:
nojava
2026-05-23 03:17:46 +08:00
parent 6e145d0980
commit 20b7a57fb9
16 changed files with 1182 additions and 9 deletions
@@ -0,0 +1,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 机制,避免额外开发
+5
View File
@@ -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) {
+8
View File
@@ -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 })
}
+1
View File
@@ -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']
+140 -2
View File
@@ -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);
}
}
@@ -5,6 +5,7 @@ import fun.nojava.common.model.PageResult;
import fun.nojava.module.blog.dto.*;
import fun.nojava.module.blog.service.AiSummaryService;
import fun.nojava.module.blog.service.ArticleService;
import fun.nojava.module.blog.service.TagRecommendService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -18,8 +19,12 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class ArticleController {
/** 仅匹配数字 ID,避免 /recommend-tags 等字面路径被当作 {id} */
private static final String ARTICLE_ID = "{id:\\d+}";
private final ArticleService articleService;
private final AiSummaryService aiSummaryService;
private final TagRecommendService tagRecommendService;
@Operation(summary = "创建文章")
@PostMapping
@@ -29,8 +34,16 @@ public class ArticleController {
return ApiResult.success(articleService.createArticle(userId, dto));
}
@Operation(summary = "AI 推荐文章标签")
@PostMapping("/recommend-tags")
public ApiResult<TagRecommendResultVO> recommendTags(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody RecommendTagsDTO dto) {
return ApiResult.success(tagRecommendService.recommendTags(userId, dto));
}
@Operation(summary = "更新文章")
@PutMapping("/{id}")
@PutMapping("/" + ARTICLE_ID)
public ApiResult<Void> updateArticle(
@AuthenticationPrincipal Long userId,
@PathVariable Long id,
@@ -50,7 +63,7 @@ public class ArticleController {
}
@Operation(summary = "移至回收站")
@PutMapping("/{id}/recycle")
@PutMapping("/" + ARTICLE_ID + "/recycle")
public ApiResult<Void> moveToRecycleBin(
@AuthenticationPrincipal Long userId,
@PathVariable Long id) {
@@ -59,7 +72,7 @@ public class ArticleController {
}
@Operation(summary = "从回收站恢复")
@PutMapping("/{id}/restore")
@PutMapping("/" + ARTICLE_ID + "/restore")
public ApiResult<Void> restoreArticle(
@AuthenticationPrincipal Long userId,
@PathVariable Long id) {
@@ -68,7 +81,7 @@ public class ArticleController {
}
@Operation(summary = "删除文章")
@DeleteMapping("/{id}")
@DeleteMapping("/" + ARTICLE_ID)
public ApiResult<Void> deleteArticle(
@AuthenticationPrincipal Long userId,
@PathVariable Long id) {
@@ -77,7 +90,7 @@ public class ArticleController {
}
@Operation(summary = "点赞文章")
@PostMapping("/{id}/like")
@PostMapping("/" + ARTICLE_ID + "/like")
public ApiResult<Boolean> toggleLike(
@PathVariable Long id,
@AuthenticationPrincipal Long userId) {
@@ -91,4 +104,5 @@ public class ArticleController {
@Valid @RequestBody GenerateSummaryDTO dto) {
return ApiResult.success(aiSummaryService.generateSummary(userId, dto));
}
}
@@ -0,0 +1,16 @@
package fun.nojava.module.blog.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class RecommendTagsDTO {
@Size(max = 200, message = "标题最长200字符")
private String title;
@NotBlank(message = "内容不能为空")
@Size(max = 50000, message = "正文过长")
private String content;
}
@@ -0,0 +1,21 @@
package fun.nojava.module.blog.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class TagRecommendResultVO {
private List<String> tags;
private String model;
private Integer promptTokens;
private Integer completionTokens;
private Long costMillis;
}
@@ -0,0 +1,9 @@
package fun.nojava.module.blog.service;
import fun.nojava.module.blog.dto.RecommendTagsDTO;
import fun.nojava.module.blog.dto.TagRecommendResultVO;
public interface TagRecommendService {
TagRecommendResultVO recommendTags(Long userId, RecommendTagsDTO dto);
}
@@ -0,0 +1,192 @@
package fun.nojava.module.blog.service.impl;
import fun.nojava.common.exception.BusinessException;
import fun.nojava.common.exception.ErrorCode;
import fun.nojava.module.blog.config.DashScopeProperties;
import fun.nojava.module.blog.dto.RecommendTagsDTO;
import fun.nojava.module.blog.dto.TagRecommendResultVO;
import fun.nojava.module.blog.service.TagRecommendService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import java.net.SocketTimeoutException;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class TagRecommendServiceImpl implements TagRecommendService {
private static final int MIN_CONTENT_LENGTH = 10;
private static final int MAX_TAGS = 5;
private static final String RATE_LIMIT_KEY_PREFIX = "tag_recommend:rate:";
private static final String SYSTEM_PROMPT =
"你是一名专业的博客编辑,请根据用户提供的标题与正文," +
"提取 3-5 个最合适的标签。只返回标签词,用逗号分隔,不要有其他内容。";
private final AiSummaryServiceImpl aiSummaryService;
private final DashScopeProperties properties;
private final StringRedisTemplate redisTemplate;
@Override
public TagRecommendResultVO recommendTags(Long userId, RecommendTagsDTO dto) {
String plainContent = stripHtml(dto.getContent());
if (plainContent.length() < MIN_CONTENT_LENGTH) {
throw new BusinessException(ErrorCode.AI_CONTENT_TOO_SHORT);
}
if (!hasApiKey()) {
throw new BusinessException(ErrorCode.AI_API_KEY_MISSING);
}
checkRateLimit(userId);
String input = plainContent.length() > properties.getMaxInput()
? plainContent.substring(0, properties.getMaxInput())
: plainContent;
String userPrompt = buildUserPrompt(dto.getTitle(), input);
long startMillis = System.currentTimeMillis();
Map<String, Object> requestBody = Map.of(
"model", properties.getModel(),
"messages", List.of(
Map.of("role", "system", "content", SYSTEM_PROMPT),
Map.of("role", "user", "content", userPrompt)
),
"temperature", 0.5
);
Map<String, Object> response;
try {
response = aiSummaryService.callModel(requestBody);
} catch (ResourceAccessException ex) {
if (ex.getCause() instanceof SocketTimeoutException) {
log.error("标签推荐 AI 调用超时, userId={}", userId);
throw new BusinessException(ErrorCode.AI_SERVICE_TIMEOUT);
}
log.error("标签推荐 AI 网络异常, userId={}, error={}", userId, ex.getMessage());
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
} catch (RestClientResponseException ex) {
log.error("标签推荐 AI HTTP 异常, status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString());
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
log.error("标签推荐 AI 调用失败, userId={}, error={}", userId, ex.getMessage(), ex);
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
long costMillis = System.currentTimeMillis() - startMillis;
TagRecommendResultVO result = parseResponse(response, costMillis);
log.info("标签推荐成功 userId={}, model={}, tags={}, cost={}ms",
userId, result.getModel(), result.getTags(), costMillis);
return result;
}
private boolean hasApiKey() {
return properties.getApiKey() != null && !properties.getApiKey().isBlank();
}
private void checkRateLimit(Long userId) {
if (userId == null) {
return;
}
String key = RATE_LIMIT_KEY_PREFIX + userId;
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
}
if (count != null && count > properties.getRateLimitPerMinute()) {
log.warn("标签推荐限流命中 userId={}, count={}", userId, count);
throw new BusinessException(ErrorCode.RATE_LIMITED);
}
}
private String buildUserPrompt(String title, String content) {
StringBuilder sb = new StringBuilder();
if (title != null && !title.isBlank()) {
sb.append("标题:").append(title.trim()).append("\n");
}
sb.append("正文:\n").append(content);
return sb.toString();
}
@SuppressWarnings("unchecked")
private TagRecommendResultVO parseResponse(Map<String, Object> response, long costMillis) {
if (response == null) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
try {
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
if (choices == null || choices.isEmpty()) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
String content = message == null ? null : (String) message.get("content");
if (content == null || content.isBlank()) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
List<String> tags = parseTags(content.trim());
Integer promptTokens = null;
Integer completionTokens = null;
Object usageObj = response.get("usage");
if (usageObj instanceof Map<?, ?> usage) {
promptTokens = toInt(usage.get("prompt_tokens"));
completionTokens = toInt(usage.get("completion_tokens"));
}
String model = (String) response.getOrDefault("model", properties.getModel());
return TagRecommendResultVO.builder()
.tags(tags)
.model(model)
.promptTokens(promptTokens)
.completionTokens(completionTokens)
.costMillis(costMillis)
.build();
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
log.error("解析标签推荐响应失败 error={}", ex.getMessage(), ex);
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
}
private List<String> parseTags(String raw) {
return Arrays.stream(raw.split("[,]"))
.map(String::trim)
.filter(tag -> !tag.isEmpty())
.limit(MAX_TAGS)
.collect(Collectors.toList());
}
private Integer toInt(Object value) {
if (value instanceof Number n) {
return n.intValue();
}
return null;
}
private String stripHtml(String content) {
if (content == null) {
return "";
}
String text = content.replaceAll("(?is)<script[^>]*>.*?</script>", " ")
.replaceAll("(?is)<style[^>]*>.*?</style>", " ")
.replaceAll("<[^>]+>", " ")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"");
return text.replaceAll("\\s+", " ").trim();
}
}
@@ -0,0 +1,384 @@
package fun.nojava.module.blog.service.impl;
import fun.nojava.common.exception.BusinessException;
import fun.nojava.common.exception.ErrorCode;
import fun.nojava.module.blog.config.DashScopeProperties;
import fun.nojava.module.blog.dto.RecommendTagsDTO;
import fun.nojava.module.blog.dto.TagRecommendResultVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class TagRecommendServiceImplTest {
private DashScopeProperties properties;
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOps;
private AiSummaryServiceImpl mockAiSummaryService;
private TagRecommendServiceImpl service;
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
properties = new DashScopeProperties();
properties.setApiKey("test-key");
properties.setBaseUrl("https://example.com/v1");
properties.setModel("qwen-turbo");
properties.setTimeout(30000);
properties.setMaxInput(8000);
properties.setRateLimitPerMinute(5);
redisTemplate = mock(StringRedisTemplate.class);
valueOps = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.increment(anyString())).thenReturn(1L);
mockAiSummaryService = mock(AiSummaryServiceImpl.class);
service = new TagRecommendServiceImpl(mockAiSummaryService, properties, redisTemplate);
}
// ==================== P0 测试 ====================
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-01: 正文过短(<10字符)应抛出 AI_CONTENT_TOO_SHORT")
void shouldRejectWhenContentTooShort() {
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle("标题");
dto.setContent("太短了");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_CONTENT_TOO_SHORT.getCode(), ex.getCode());
verify(valueOps, never()).increment(anyString());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-02: 未配置 API Key 应抛出 AI_API_KEY_MISSING")
void shouldRejectWhenApiKeyMissing() {
properties.setApiKey("");
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_API_KEY_MISSING.getCode(), ex.getCode());
verify(valueOps, never()).increment(anyString());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-03: 单用户超过限流应触发 RATE_LIMITED")
void shouldThrowRateLimitedWhenExceedsLimit() {
when(valueOps.increment("tag_recommend:rate:7")).thenReturn(6L);
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(7L, dto));
assertEquals(ErrorCode.RATE_LIMITED.getCode(), ex.getCode());
verify(mockAiSummaryService, never()).callModel(any());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-04: 正常返回应填充 tags、model、tokens、耗时")
void shouldReturnTagsOnSuccess() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring Boot, 微服务"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle("Spring Cloud 微服务实践");
dto.setContent("这是一篇关于微服务架构的详细技术文章,涵盖服务注册与发现、配置中心等内容。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertNotNull(result.getTags());
assertEquals(3, result.getTags().size());
assertEquals("Java", result.getTags().get(0));
assertEquals("Spring Boot", result.getTags().get(1));
assertEquals("微服务", result.getTags().get(2));
assertEquals("qwen-turbo", result.getModel());
assertEquals(123, result.getPromptTokens());
assertEquals(45, result.getCompletionTokens());
assertNotNull(result.getCostMillis());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-05: 标签数量不应超过 MAX_TAGS(5个)")
void shouldLimitTagsToMax() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring, 微服务, Docker, K8s, 多余标签, 更多"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals(5, result.getTags().size());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-06: 支持中英文逗号混合分隔")
void shouldParseCommaAndChineseComma() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("JavaSpring Boot,微服务"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals(3, result.getTags().size());
assertEquals("Java", result.getTags().get(0));
assertEquals("Spring Boot", result.getTags().get(1));
assertEquals("微服务", result.getTags().get(2));
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-07: 空白标签应被过滤")
void shouldFilterEmptyTags() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, , Spring, , 微服务"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals(3, result.getTags().size());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-08: 正文中的 HTML 标签应被剥离")
void shouldStripHtmlBeforeCallingModel() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring Boot"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("<p>这是一段 <strong>HTML</strong> 富文本,足够长以通过校验。</p>");
service.recommendTags(1L, dto);
verify(mockAiSummaryService).callModel(argThat(requestBody -> {
@SuppressWarnings("unchecked")
List<Map<String, Object>> messages = (List<Map<String, Object>>) requestBody.get("messages");
String userContent = (String) messages.get(1).get("content");
assertFalse(userContent.contains("<p>"), "HTML 标签未被剥离: " + userContent);
assertFalse(userContent.contains("<strong>"), "HTML 标签未被剥离: " + userContent);
assertTrue(userContent.contains("HTML"), "纯文本内容缺失");
return true;
}));
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-09: choices 为空应映射为 AI_SERVICE_UNAVAILABLE")
void shouldFailWhenChoicesEmpty() {
Map<String, Object> response = new java.util.HashMap<>();
response.put("choices", List.of());
response.put("model", "qwen-turbo");
when(mockAiSummaryService.callModel(any())).thenReturn(response);
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
}
@Test
@Tag("p0")
@DisplayName("TC-TAG-REC-10: 模型调用异常应映射为 AI_SERVICE_UNAVAILABLE")
void shouldMapExceptionToAiServiceUnavailable() {
when(mockAiSummaryService.callModel(any()))
.thenThrow(new RuntimeException("网络异常"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
}
// ==================== P1 测试 ====================
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-11: 标题为 null 时应正常调用模型")
void shouldWorkWhenTitleIsNull() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("标签A, 标签B"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle(null);
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertNotNull(result.getTags());
assertEquals(2, result.getTags().size());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-12: 长正文应截断到 maxInput 上限")
void shouldTruncateLongContentToMaxInput() {
properties.setMaxInput(50);
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("X".repeat(500));
service.recommendTags(1L, dto);
verify(mockAiSummaryService).callModel(argThat(requestBody -> {
@SuppressWarnings("unchecked")
List<Map<String, Object>> messages = (List<Map<String, Object>>) requestBody.get("messages");
String userContent = (String) messages.get(1).get("content");
// 正文部分应该被截断到 50 个字符
long xCount = userContent.chars().filter(c -> c == 'X').count();
assertEquals(50, xCount, "正文应截断到 maxInput 字符");
return true;
}));
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-13: 首次调用应设置限流 key 的 1 分钟 TTL")
void shouldExpireRateLimitKeyOnFirstCall() {
when(valueOps.increment("tag_recommend:rate:8")).thenReturn(1L);
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
service.recommendTags(8L, dto);
verify(redisTemplate, times(1)).expire(eq("tag_recommend:rate:8"), eq(1L), any());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-14: userId 为 null 时不应限流,应正常调用模型")
void shouldNotRateLimitWhenUserIdNull() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("Java, Spring"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(null, dto);
assertNotNull(result.getTags());
assertEquals(2, result.getTags().size());
verify(mockAiSummaryService).callModel(any());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-15: 模型返回 content 为 blank 应映射为 AI_SERVICE_UNAVAILABLE")
void shouldFailWhenContentIsBlank() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse(" "));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
BusinessException ex = assertThrows(BusinessException.class,
() -> service.recommendTags(1L, dto));
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-16: 响应中无 usage 字段时 tokens 应为 null")
void shouldReturnNullTokensWhenNoUsage() {
Map<String, Object> response = Map.of(
"model", "qwen-turbo",
"choices", List.of(
Map.of("message", Map.of("role", "assistant", "content", "Java, Spring"))
)
);
when(mockAiSummaryService.callModel(any())).thenReturn(response);
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertNull(result.getPromptTokens());
assertNull(result.getCompletionTokens());
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-17: 模型返回单个标签应正常解析")
void shouldWorkWithSingleTag() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse("全栈开发"));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals(1, result.getTags().size());
assertEquals("全栈开发", result.getTags().get(0));
}
@Test
@Tag("p1")
@DisplayName("TC-TAG-REC-18: 模型返回前后带空格的标签应去除空格")
void shouldTrimTagWhitespace() {
when(mockAiSummaryService.callModel(any()))
.thenReturn(buildModelResponse(" Java , Spring Boot , 微服务 "));
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setContent("这是一篇足够长的文章正文内容,用于触发标签推荐测试用例。");
TagRecommendResultVO result = service.recommendTags(1L, dto);
assertEquals("Java", result.getTags().get(0));
assertEquals("Spring Boot", result.getTags().get(1));
assertEquals("微服务", result.getTags().get(2));
}
// ==================== 辅助方法 ====================
private Map<String, Object> buildModelResponse(String tagsContent) {
return Map.of(
"model", "qwen-turbo",
"choices", List.of(
Map.of("message", Map.of("role", "assistant", "content", tagsContent))
),
"usage", Map.of(
"prompt_tokens", 123,
"completion_tokens", 45,
"total_tokens", 168
)
);
}
}
@@ -0,0 +1,137 @@
package fun.nojava.module.blog.service.impl;
import fun.nojava.module.blog.config.DashScopeProperties;
import fun.nojava.module.blog.dto.RecommendTagsDTO;
import fun.nojava.module.blog.dto.TagRecommendResultVO;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* 真实调用阿里云百炼(DashScope)的标签推荐联调测试。
* <p>
* 仅在环境变量 {@code DASHSCOPE_API_KEY} 存在时启用,避免在 CI 中无端消耗 token。
* <p>
* 本测试不操作数据库;Redis 限流计数器使用极大的 fake userId 隔离,结束时主动清理。
*/
@EnabledIfEnvironmentVariable(named = "DASHSCOPE_API_KEY", matches = ".+")
class TagRecommendServiceLiveIT {
/** 远离任何真实用户 ID 的隔离 userId */
private static final long FAKE_USER_ID = 9_999_999_998L;
private DashScopeProperties properties;
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOps;
private AiSummaryServiceImpl aiSummaryService;
private TagRecommendServiceImpl service;
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
String apiKey = System.getenv("DASHSCOPE_API_KEY");
assertNotNull(apiKey, "DASHSCOPE_API_KEY 未设置");
properties = new DashScopeProperties();
properties.setApiKey(apiKey);
properties.setBaseUrl(envOrDefault("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"));
properties.setModel(envOrDefault("DASHSCOPE_MODEL", "qwen-turbo"));
properties.setTimeout(30000);
properties.setMaxInput(8000);
properties.setRateLimitPerMinute(50);
redisTemplate = mock(StringRedisTemplate.class);
valueOps = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.increment(anyString())).thenReturn(1L);
aiSummaryService = new AiSummaryServiceImpl(properties, redisTemplate);
aiSummaryService.init();
service = new TagRecommendServiceImpl(aiSummaryService, properties, redisTemplate);
}
@AfterEach
void tearDown() {
if (redisTemplate != null) {
try {
redisTemplate.delete("tag_recommend:rate:" + FAKE_USER_ID);
} catch (Exception ignored) {
}
}
}
@Test
@Tag("live")
@DisplayName("TC-TAG-REC-LIVE-01: 真实调用百炼应返回 3-5 个有效标签")
void shouldRecommendRealTags() {
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle("Spring Boot 微服务实战");
dto.setContent("Spring Boot 是一个用于简化 Spring 应用开发的开源框架。"
+ "它通过自动配置、起步依赖和内嵌服务器等特性,大幅降低了 Spring 项目的搭建成本。"
+ "在微服务架构中,Spring Boot 结合 Spring Cloud 提供了服务注册与发现(Eureka/Nacos)、"
+ "配置中心、API 网关(Gateway)、负载均衡、熔断降级(Resilience4j)等全套解决方案。"
+ "本文从服务拆分原则、Docker 容器化、Kubernetes 部署等方面,"
+ "系统介绍如何基于 Spring Boot 构建生产级微服务体系。");
TagRecommendResultVO result = service.recommendTags(FAKE_USER_ID, dto);
assertNotNull(result, "返回结果不应为 null");
assertNotNull(result.getTags(), "tags 列表不应为 null");
assertFalse(result.getTags().isEmpty(), "tags 列表不应为空");
assertTrue(result.getTags().size() >= 1, "至少应返回 1 个标签");
assertTrue(result.getTags().size() <= 5, "标签数量不应超过 5 个");
// 每个标签不应为空
for (String tag : result.getTags()) {
assertNotNull(tag, "单个标签不应为 null");
assertFalse(tag.isBlank(), "单个标签不应为空白");
}
assertNotNull(result.getModel(), "model 字段不应为 null");
assertNotNull(result.getCostMillis(), "costMillis 不应为 null");
System.out.println("=== Live DashScope Tag Recommend Test ===");
System.out.println("model = " + result.getModel());
System.out.println("promptTokens = " + result.getPromptTokens());
System.out.println("completionTokens= " + result.getCompletionTokens());
System.out.println("costMillis = " + result.getCostMillis());
System.out.println("tags = " + result.getTags());
System.out.println("========================================");
}
@Test
@Tag("live")
@DisplayName("TC-TAG-REC-LIVE-02: 真实调用应返回逗号可分隔的标签文本")
void shouldReturnCommaSeparableTags() {
RecommendTagsDTO dto = new RecommendTagsDTO();
dto.setTitle("Java 并发编程");
dto.setContent("Java 并发编程是高级开发者的必备技能。本文深入探讨了 Java 内存模型(JMM)、"
+ "synchronized 与 ReentrantLock 的底层实现、CAS 无锁算法、线程池 ThreadPoolExecutor "
+ "的七大参数与拒绝策略、以及 CompletableFuture 异步编程模型。"
+ "同时结合实际生产案例,分析死锁诊断、性能优化、JUC 工具类选型等常见问题与解决方案。");
TagRecommendResultVO result = service.recommendTags(FAKE_USER_ID, dto);
assertNotNull(result.getTags());
assertTrue(result.getTags().size() >= 1);
System.out.println("=== Live Tag Recommend Test 2 ===");
System.out.println("tags = " + result.getTags());
System.out.println("==================================");
}
private String envOrDefault(String name, String defaultValue) {
String value = System.getenv(name);
return (value == null || value.isBlank()) ? defaultValue : value;
}
}
@@ -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 = "密码不能为空")
+1
View File
@@ -27,6 +27,7 @@
</modules>
<properties>
<maven.frontend.skip>false</maven.frontend.skip>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>