后台自动选择分类和标签功能实现
最近给博客后台加了个功能:写文章时,可以让 AI 自动选择分类和标签。AI 会从已有的分类和标签中选择最合适的,如果没有合适的也可以建议新的。不用自己想了,AI 帮你搞定。
一、功能需求
- 自动选择分类:AI 从已有分类中选择最合适的,如果没有合适的可以建议新分类
- 自动选择标签:AI 从已有标签中选择 1-3 个,如果没有合适的可以创建新标签
- 两个功能都在发布文章的弹窗中,点击「AI 选择」按钮触发
二、技术选型
- 后端:Spring Boot + OkHttp + DeepSeek/硅基流动 API
- 前端:Vue 3 + TypeScript + Axios + Element Plus
三、后端实现
1. 添加依赖
如果你的项目还没有 OkHttp 依赖,需要在 pom.xml 中添加:
xml
<!-- OkHttp (用于 AI 请求) -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
2. 配置文件
在 application-dev.yml 中添加 AI 配置(如果还没有):
yaml
# AI 对话配置
ai:
deepseek:
api-key: ${AI_API_KEY:sk-你的API密钥}
api-url: ${AI_API_URL:https://api.siliconflow.cn/v1/chat/completions}
model: ${AI_MODEL:deepseek-ai/DeepSeek-V3-0324}
system-prompt: ${AI_SYSTEM_PROMPT:你是一个博客智能助手,帮助用户解答技术问题。请用简洁、专业的中文回答,支持 Markdown 格式。}
3. AI 服务类
在 AiService.java 中添加两个方法:
java
package com.ican.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ican.config.properties.DeepSeekProperties;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class AiService {
@Autowired
private DeepSeekProperties deepSeekProperties;
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
/**
* 自动选择分类
*
* @param content 文章内容
* @param categories 已有分类列表(逗号分隔)
* @return 分类名称
*/
public String autoCategory(String content, String categories) {
// 截取前 2000 字,足够判断分类
String truncated = content.length() > 2000 ? content.substring(0, 2000) : content;
JSONArray messagesArray = new JSONArray();
// 系统提示词
JSONObject systemMsg = new JSONObject();
systemMsg.put("role", "system");
systemMsg.put("content", "你是一个文章分类助手。根据文章内容,从给定的分类列表中选择最合适的一个分类。\n" +
"如果没有合适的分类,可以建议一个新的分类名称(不超过10个字)。\n" +
"要求:只输出分类名称,不要加任何解释。");
messagesArray.add(systemMsg);
// 用户消息
JSONObject userMsg = new JSONObject();
userMsg.put("role", "user");
userMsg.put("content", "已有分类列表:" + (categories != null ? categories : "无") +
"\n\n文章内容:\n" + truncated);
messagesArray.add(userMsg);
JSONObject requestBody = new JSONObject();
requestBody.put("model", deepSeekProperties.getModel());
requestBody.put("messages", messagesArray);
requestBody.put("stream", false); // 非流式,同步返回
requestBody.put("temperature", 0.3); // 降低随机性,分类选择更稳定
requestBody.put("max_tokens", 30); // 限制输出长度,分类名称不会太长
Request request = new Request.Builder()
.url(deepSeekProperties.getApiUrl())
.addHeader("Authorization", "Bearer " + deepSeekProperties.getApiKey())
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(requestBody.toJSONString(), MediaType.parse("application/json")))
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
String body = response.body() != null ? response.body().string() : "";
log.error("AI 分类选择失败: {} {}", response.code(), body);
throw new RuntimeException("AI 分类选择失败: " + response.code());
}
String responseBody = response.body().string();
JSONObject jsonResponse = JSON.parseObject(responseBody);
JSONArray choices = jsonResponse.getJSONArray("choices");
if (choices != null && !choices.isEmpty()) {
JSONObject message = choices.getJSONObject(0).getJSONObject("message");
if (message != null) {
String result = message.getString("content");
return result != null ? result.trim() : "";
}
}
return "";
} catch (IOException e) {
log.error("AI 分类选择异常", e);
throw new RuntimeException("AI 分类选择异常: " + e.getMessage());
}
}
/**
* 自动选择标签
*
* @param content 文章内容
* @param tags 已有标签列表(逗号分隔)
* @return 标签名列表(最多3个)
*/
public List<String> autoTags(String content, String tags) {
// 截取前 2000 字,足够判断标签
String truncated = content.length() > 2000 ? content.substring(0, 2000) : content;
JSONArray messagesArray = new JSONArray();
// 系统提示词
JSONObject systemMsg = new JSONObject();
systemMsg.put("role", "system");
systemMsg.put("content", "你是一个文章标签生成助手。根据文章内容,选择或生成1-3个合适的标签。\n" +
"优先从已有标签列表中选择,如果没有合适的可以创建新标签。\n" +
"要求:只输出标签名,用逗号分隔,不要加解释。例如:Java,Spring Boot,数据库");
messagesArray.add(systemMsg);
// 用户消息
JSONObject userMsg = new JSONObject();
userMsg.put("role", "user");
userMsg.put("content", "已有标签列表:" + (tags != null ? tags : "无") +
"\n\n文章内容:\n" + truncated);
messagesArray.add(userMsg);
JSONObject requestBody = new JSONObject();
requestBody.put("model", deepSeekProperties.getModel());
requestBody.put("messages", messagesArray);
requestBody.put("stream", false); // 非流式,同步返回
requestBody.put("temperature", 0.3); // 降低随机性,标签选择更稳定
requestBody.put("max_tokens", 60); // 限制输出长度,约 1-3 个标签
Request request = new Request.Builder()
.url(deepSeekProperties.getApiUrl())
.addHeader("Authorization", "Bearer " + deepSeekProperties.getApiKey())
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(requestBody.toJSONString(), MediaType.parse("application/json")))
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
String body = response.body() != null ? response.body().string() : "";
log.error("AI 标签选择失败: {} {}", response.code(), body);
throw new RuntimeException("AI 标签选择失败: " + response.code());
}
String responseBody = response.body().string();
JSONObject jsonResponse = JSON.parseObject(responseBody);
JSONArray choices = jsonResponse.getJSONArray("choices");
if (choices != null && !choices.isEmpty()) {
JSONObject message = choices.getJSONObject(0).getJSONObject("message");
if (message != null) {
String result = message.getString("content");
if (result == null || result.trim().isEmpty()) {
return new ArrayList<>();
}
// 按逗号或中文逗号分割
String[] tagArray = result.trim().split("[,,、]");
List<String> tagList = new ArrayList<>();
for (String t : tagArray) {
String trimmed = t.trim();
if (!trimmed.isEmpty()) {
tagList.add(trimmed);
}
}
// 限制最多 3 个标签
return tagList.size() > 3 ? tagList.subList(0, 3) : tagList;
}
}
return new ArrayList<>();
} catch (IOException e) {
log.error("AI 标签选择异常", e);
throw new RuntimeException("AI 标签选择异常: " + e.getMessage());
}
}
}
关键点说明:
temperature: 0.3:降低随机性,选择更稳定max_tokens: 30/60:限制输出长度- 截取前 2000 字:足够判断分类/标签
- 系统提示词明确要求:只输出名称,不要解释
- 标签分割:支持英文逗号、中文逗号、顿号
- 标签数量限制:最多 3 个
4. 控制器
在 AiController.java 中添加两个接口:
java
package com.ican.controller;
import com.ican.model.vo.Result;
import com.ican.service.AiService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* AI 对话控制器
*
* @author ican
*/
@Api(tags = "AI 模块")
@RestController
public class AiController {
@Autowired
private AiService aiService;
/**
* AI 自动选择分类
*
* @param body 请求体,包含 content(文章内容)和 categories(已有分类,逗号分隔)
* @return 分类名称
*/
@ApiOperation(value = "AI 自动选择分类")
@PostMapping("/admin/ai/category")
public Result<String> autoCategory(@RequestBody Map<String, String> body) {
String content = body.get("content");
String categories = body.get("categories");
if (content == null || content.trim().isEmpty()) {
return Result.fail("文章内容不能为空");
}
return Result.success(aiService.autoCategory(content, categories));
}
/**
* AI 自动选择标签
*
* @param body 请求体,包含 content(文章内容)和 tags(已有标签,逗号分隔)
* @return 标签名列表(最多3个)
*/
@ApiOperation(value = "AI 自动选择标签")
@PostMapping("/admin/ai/tags")
public Result<List<String>> autoTags(@RequestBody Map<String, String> body) {
String content = body.get("content");
String tags = body.get("tags");
if (content == null || content.trim().isEmpty()) {
return Result.fail("文章内容不能为空");
}
return Result.success(aiService.autoTags(content, tags));
}
}
接口放在 /admin/ 路径下,需要登录才能访问(后台管理功能)。
四、前端实现
1. API 函数
在 api/article/index.ts 中添加:
typescript
import { Result } from "@/model";
import request from "@/utils/request";
import { AxiosPromise } from "axios";
/**
* AI 自动选择分类
* @param content 文章内容
* @param categories 已有分类(逗号分隔)
* @returns 分类名称
*/
export function generateAiCategory(content: string, categories: string): AxiosPromise<Result<string>> {
return request({
url: "/admin/ai/category",
method: "post",
timeout: 60000,
data: { content, categories },
});
}
/**
* AI 自动选择标签
* @param content 文章内容
* @param tags 已有标签(逗号分隔)
* @returns 标签名列表
*/
export function generateAiTags(content: string, tags: string): AxiosPromise<Result<string[]>> {
return request({
url: "/admin/ai/tags",
method: "post",
timeout: 60000,
data: { content, tags },
});
}
/**
* 获取分类选项
* @returns 分类选项
*/
export function getCategoryOption(): AxiosPromise<Result<CategoryVO[]>> {
return request({
url: "/admin/category/option",
method: "get",
});
}
/**
* 获取标签选项
* @returns 标签选项
*/
export function getTagOption(): AxiosPromise<Result<TagVO[]>> {
return request({
url: "/admin/tag/option",
method: "get",
});
}
2. 页面添加按钮和逻辑
在 views/blog/article/write.vue 中:
vue
<template>
<div class="app-container">
<!-- ... 文章编辑区域 ... -->
<!-- 发布或修改对话框 -->
<el-dialog title="发布文章" v-model="addOrUpdate" width="600px" top="0.5vh" append-to-body>
<el-form ref="articleFormRef" label-width="80px" :model="articleForm" :rules="rules">
<!-- 文章分类 -->
<el-form-item label="文章分类" prop="categoryName">
<el-tag type="success" v-show="articleForm.categoryName"
:disable-transitions="true" :closable="true" @close="removeCategory">
{{ articleForm.categoryName }}
</el-tag>
<!-- 分类选项 -->
<el-popover v-if="!articleForm.categoryName" placement="bottom-start" width="460" trigger="click">
<template #reference>
<el-button type="success" plain>添加分类</el-button>
</template>
<!-- ... 分类选择组件 ... -->
</el-popover>
<el-button v-if="!articleForm.categoryName" type="primary"
:loading="aiCategoryLoading" :icon="MagicStick"
style="margin-left: 8px" @click="handleAiCategory">
AI 选择
</el-button>
</el-form-item>
<!-- 文章标签 -->
<el-form-item label="文章标签" prop="tagNameList">
<el-tag v-for="(item, index) of articleForm.tagNameList" :key="index"
:disable-transitions="true" :closable="true" @close="removeTag(item)"
style="margin-right: 1rem">
{{ item }}
</el-tag>
<!-- 标签选项 -->
<el-popover placement="bottom-start" width="460" trigger="click"
v-if="articleForm.tagNameList.length < 3">
<template #reference>
<el-button type="success" plain>添加标签</el-button>
</template>
<!-- ... 标签选择组件 ... -->
</el-popover>
<el-button v-if="articleForm.tagNameList.length === 0" type="primary"
:loading="aiTagLoading" :icon="MagicStick"
style="margin-left: 8px" @click="handleAiTags">
AI 选择
</el-button>
</el-form-item>
<!-- ... 其他表单项 ... -->
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button v-if="articleForm.status != 3" type="danger" @click="submitForm">发布文章</el-button>
<el-button v-else type="danger" @click="submitForm">保存草稿</el-button>
<el-button @click="addOrUpdate = false">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { generateAiCategory, generateAiTags, getCategoryOption, getTagOption } from "@/api/article";
import { CategoryVO, TagVO } from "@/api/article/types";
import { MagicStick } from '@element-plus/icons-vue';
import { ElMessage } from "element-plus";
// 文章表单数据
const articleForm = ref({
articleTitle: "",
articleContent: "",
categoryName: "",
tagNameList: [] as string[],
// ... 其他字段
});
// 分类和标签列表
const categoryList = ref<CategoryVO[]>([]);
const tagList = ref<TagVO[]>([]);
// AI 自动选择分类
const aiCategoryLoading = ref(false);
const handleAiCategory = async () => {
const content = articleForm.value.articleContent;
if (!content || content.trim() === "") {
ElMessage.warning("请先编写文章内容");
return;
}
aiCategoryLoading.value = true;
try {
// 确保分类列表已加载
if (categoryList.value.length === 0) {
const res = await getCategoryOption();
categoryList.value = res.data.data;
}
const categoriesStr = categoryList.value.map((c) => c.categoryName).join(",");
const { data } = await generateAiCategory(content, categoriesStr);
if (data.flag && data.data) {
articleForm.value.categoryName = data.data.trim();
ElMessage.success(`AI 已选择分类: ${articleForm.value.categoryName}`);
} else {
ElMessage.error(data.msg || "AI 分类选择失败");
}
} catch {
ElMessage.error("AI 分类选择失败,请稍后重试");
} finally {
aiCategoryLoading.value = false;
}
};
// AI 自动选择标签
const aiTagLoading = ref(false);
const handleAiTags = async () => {
const content = articleForm.value.articleContent;
if (!content || content.trim() === "") {
ElMessage.warning("请先编写文章内容");
return;
}
aiTagLoading.value = true;
try {
// 确保标签列表已加载
if (tagList.value.length === 0) {
const res = await getTagOption();
tagList.value = res.data.data;
}
const tagsStr = tagList.value.map((t) => t.tagName).join(",");
const { data } = await generateAiTags(content, tagsStr);
if (data.flag && data.data) {
const newTags = data.data;
newTags.forEach((t: string) => {
if (articleForm.value.tagNameList.indexOf(t) === -1 &&
articleForm.value.tagNameList.length < 3) {
articleForm.value.tagNameList.push(t);
}
});
ElMessage.success(`AI 已选择标签: ${newTags.join(", ")}`);
} else {
ElMessage.error(data.msg || "AI 标签选择失败");
}
} catch {
ElMessage.error("AI 标签选择失败,请稍后重试");
} finally {
aiTagLoading.value = false;
}
};
// 打开发布对话框时加载分类和标签列表
const openModel = () => {
// ... 其他逻辑 ...
getCategoryOption().then(({ data }) => {
categoryList.value = data.data;
});
getTagOption().then(({ data }) => {
tagList.value = data.data;
});
addOrUpdate.value = true;
};
// 移除分类
const removeCategory = () => {
articleForm.value.categoryName = "";
};
// 移除标签
const removeTag = (item: string) => {
const index = articleForm.value.tagNameList.indexOf(item);
articleForm.value.tagNameList.splice(index, 1);
};
</script>
五、使用效果
- 在后台写文章页面,写好文章内容
- 点击「发布文章」打开弹窗
- 在「文章分类」下方点击「AI 选择」按钮
- 等待几秒(按钮显示 loading)
- AI 自动选择分类并填入(如果没有合适的分类,会建议新分类)
- 在「文章标签」下方点击「AI 选择」按钮
- AI 自动选择 1-3 个标签并填入(如果没有合适的标签,会创建新标签)
六、遇到的问题
1. AI 返回格式问题
问题:AI 可能返回带解释的文本(如「根据文章内容,我建议选择"后端开发"这个分类」)。
解决:在系统提示词中明确要求「只输出分类名称,不要加任何解释」。后端再做一些清理。
2. 标签数量控制
问题:AI 可能返回超过 3 个标签。
解决 :在系统提示词中明确要求「1-3个标签」。后端限制最多 3 个:tagList.size() > 3 ? tagList.subList(0, 3) : tagList。
3. 分类/标签列表未加载
问题:用户点击「AI 选择」时,分类/标签列表可能还没加载。
解决 :前端检查列表是否为空,为空时先调用 getCategoryOption() 或 getTagOption() 加载列表。
4. 标签分割问题
问题:AI 可能用中文逗号或顿号分隔标签。
解决 :后端用正则表达式分割:split("[,,、]"),支持英文逗号、中文逗号、顿号。
七、总结
这两个功能实现起来不算复杂:
- 后端:新增两个同步调用的 AI 接口,分别用于选择分类和标签
- 前端:加个按钮,调用 API,把结果填入表单
关键是:
- 系统提示词要明确(只输出名称,不要解释)
- 设置合适的
temperature(降低随机性)和max_tokens(限制长度) - 前端设置足够的超时时间
- 处理错误情况,给用户友好提示
- 后端处理标签分割和数量限制
现在写文章时,分类和标签可以一键选择,省了不少时间。如果选择的不满意,也可以手动修改或删除。
八、完整代码文件清单
后端:
pom.xml- 添加 OkHttp 依赖application-dev.yml- 添加 AI 配置AiService.java- 添加autoCategory和autoTags方法AiController.java- 添加/admin/ai/category和/admin/ai/tags接口
前端:
api/article/index.ts- 添加generateAiCategory和generateAiTags函数views/blog/article/write.vue- 添加分类和标签的 AI 选择按钮和逻辑
按照上面的步骤,就可以在自己的项目中实现这个功能了。