集成AI摘要
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
# AI 智能摘要生成功能 - 需求规格说明书
|
||||
|
||||
| 文档版本 | 修订日期 | 修订人 | 修订内容 |
|
||||
| :------- | :--------- | :----- | :-------------------------------------- |
|
||||
| V1.0 | 2026-05-21 | — | 初始版本,定义 AI 摘要生成功能完整需求 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 功能背景
|
||||
|
||||
为提升博客创作者的写作效率与文章发布质量,在 Jog 博客系统的"文章发布"页面新增 **AI 智能摘要生成** 功能。用户在撰写完文章正文后,可一键调用大模型自动生成一段高质量的文章摘要,无需手动撰写。
|
||||
|
||||
### 1.2 功能目标
|
||||
|
||||
- 降低创作者撰写摘要的时间成本,提升发布效率
|
||||
- 通过 AI 自动提炼文章核心内容,提升摘要质量与一致性
|
||||
- 完成与阿里云百炼(DashScope)大模型平台的对接,为后续 AI 能力(如智能标签、文章润色、相关推荐)打下基础
|
||||
|
||||
### 1.3 适用范围
|
||||
|
||||
| 角色 | 涉及范围 |
|
||||
| :------------- | :-------------------------------------------------------- |
|
||||
| 产品经理 | 验证功能形态与交互流程 |
|
||||
| 前端开发工程师 | 实现"生成摘要"按钮与调用流程 |
|
||||
| 后端开发工程师 | 接入阿里云百炼大模型,封装统一接口 |
|
||||
| 测试工程师 | 编写自动化测试与回归用例 |
|
||||
| 运维工程师 | 维护 `dashscope.api-key` 等敏感配置项 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 名词解释
|
||||
|
||||
| 术语 | 说明 |
|
||||
| :------------------ | :------------------------------------------------------------------------------------- |
|
||||
| 阿里云百炼 | 阿里云推出的一站式大模型服务平台,提供 Qwen 系列等模型的统一 API(DashScope) |
|
||||
| DashScope | 阿里云百炼对外暴露的 API 服务名,本系统对接其 OpenAI 兼容模式 |
|
||||
| OpenAI 兼容模式 | DashScope 兼容 OpenAI ChatCompletion 协议的接入方式,便于切换底层模型与生态工具复用 |
|
||||
| Qwen-Turbo | 通义千问 Turbo 系列模型,速度快、成本低,适合文本摘要等轻量任务,本功能默认使用该模型 |
|
||||
| 文章发布位置 | 指 `jog-frontend/src/views/app/ArticleEditor.vue` 中"摘要"字段所在的表单区域 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 功能需求清单
|
||||
|
||||
### 3.1 入口与按钮交互(前端)
|
||||
|
||||
#### 3.1.1 功能描述
|
||||
|
||||
在文章编辑器页面(`/app/editor` 与 `/app/editor/:id`)的"摘要"输入框旁边新增 **"生成摘要"** 按钮。用户点击按钮后,系统自动根据已输入的标题与正文调用后端大模型接口,将返回的摘要回填到摘要输入框中。
|
||||
|
||||
#### 3.1.2 业务规则
|
||||
|
||||
| 编号 | 规则项 | 业务规则说明 |
|
||||
| :------------------- | :--------------- | :---------------------------------------------------------------------------------------------------------------------- |
|
||||
| FR-AI-SUMMARY-FE-01 | 按钮位置 | 按钮显示在"摘要"输入框下方右侧(与`textarea`同一表单项内),文案为"AI 生成摘要",主色调使用 Element Plus `type="primary"` |
|
||||
| FR-AI-SUMMARY-FE-02 | 触发前置条件 | 点击按钮时,正文(去除 HTML 标签后纯文本)字符数 ≥ 20 才允许调用;否则提示"正文过短,无法生成摘要" |
|
||||
| FR-AI-SUMMARY-FE-03 | Loading 状态 | 按钮在请求期间显示 `loading` 态并禁用,防止用户重复点击 |
|
||||
| FR-AI-SUMMARY-FE-04 | 已有摘要确认 | 若摘要框已有内容,点击按钮前需弹出确认框:"已有摘要,确认覆盖?";用户确认后才发起调用 |
|
||||
| FR-AI-SUMMARY-FE-05 | 成功回填 | 接口返回成功后,将生成的摘要自动填充到摘要输入框,并提示"摘要生成成功" |
|
||||
| FR-AI-SUMMARY-FE-06 | 失败提示 | 接口返回失败时,提示后端返回的 `message`,按钮恢复可点击状态 |
|
||||
| FR-AI-SUMMARY-FE-07 | 超时提示 | 请求超过 30 秒未返回,提示"AI 服务响应超时,请稍后再试" |
|
||||
|
||||
#### 3.1.3 验收标准
|
||||
|
||||
| 编号 | 验证场景 | 预期结果 |
|
||||
| :------------------- | :---------------------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||
| AC-AI-SUMMARY-FE-01 | 正文为空时点击"AI 生成摘要" | 提示"正文过短,无法生成摘要",不发起后端请求 |
|
||||
| AC-AI-SUMMARY-FE-02 | 正文字符数 ≥ 20,点击按钮 | 按钮进入 loading 态,请求成功后摘要框被自动填充 |
|
||||
| AC-AI-SUMMARY-FE-03 | 摘要框已有内容时点击按钮 | 弹出"已有摘要,确认覆盖?"对话框;取消则不调用接口,确定则继续调用并覆盖 |
|
||||
| AC-AI-SUMMARY-FE-04 | 后端返回 5001 错误码(AI 服务异常) | 提示"AI 服务暂时不可用,请稍后再试",按钮恢复可用 |
|
||||
| AC-AI-SUMMARY-FE-05 | 在编辑文章(带 `:id`)页面同样可使用该按钮 | 行为与新建文章一致 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 AI 摘要生成接口(后端)
|
||||
|
||||
#### 3.2.1 功能描述
|
||||
|
||||
后端新增 **`POST /api/app/article/summary`** 接口,接收文章标题与正文,调用阿里云百炼大模型生成摘要后返回给前端。
|
||||
|
||||
#### 3.2.2 业务规则
|
||||
|
||||
| 编号 | 规则项 | 业务规则说明 |
|
||||
| :------------------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FR-AI-SUMMARY-BE-01 | 接口路径与方法 | `POST /api/app/article/summary`,需登录用户调用(继承 `/api/app/**` 的 `USER`/`ADMIN` 权限校验) |
|
||||
| FR-AI-SUMMARY-BE-02 | 请求参数 | `title`(可选,最长 200)、`content`(必填,最长 50000,提交前已去除 HTML 标签) |
|
||||
| FR-AI-SUMMARY-BE-03 | 入参校验 | `content` 为空或去除 HTML 后字符数 < 20,返回 `400 BAD_REQUEST` 与提示"正文过短,无法生成摘要" |
|
||||
| FR-AI-SUMMARY-BE-04 | 内容截断 | 若 `content` 字符数 > 8000,仅取前 8000 字符送入大模型,避免超过模型输入限制 |
|
||||
| FR-AI-SUMMARY-BE-05 | 模型与平台 | 默认调用阿里云百炼(DashScope)OpenAI 兼容模式:<br>Endpoint: `https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions`<br>模型: `qwen-turbo` |
|
||||
| FR-AI-SUMMARY-BE-06 | 提示词(Prompt) | System:你是一名专业的博客编辑,请根据用户提供的标题与正文生成 100~150 字的中文摘要,要求语言精炼、概括核心观点,不使用第一人称<br>User:标题:xxx 正文:xxx |
|
||||
| FR-AI-SUMMARY-BE-07 | 摘要长度限制 | 返回前端的摘要在 80~200 字之间;若模型返回过长则截断到 200 字并以 `…` 结尾 |
|
||||
| FR-AI-SUMMARY-BE-08 | 鉴权方式 | 调用 DashScope 时通过 `Authorization: Bearer ${dashscope.api-key}` 传递 API Key |
|
||||
| FR-AI-SUMMARY-BE-09 | 配置项 | 在 `application.yml` 中提供 `dashscope.api-key`、`dashscope.base-url`、`dashscope.model`、`dashscope.timeout` 四个配置项,支持环境变量覆盖 |
|
||||
| FR-AI-SUMMARY-BE-10 | 超时与重试 | 单次调用超时 30 秒,不做自动重试;超时返回 `5002` 错误码 |
|
||||
| FR-AI-SUMMARY-BE-11 | 限流 | 同一用户每分钟最多调用 5 次(基于 `@RateLimit`),超出返回 `429 RATE_LIMITED` |
|
||||
| FR-AI-SUMMARY-BE-12 | 异常处理 | DashScope 返回非 2xx 或 JSON 结构异常时,统一抛出 `BusinessException`,错误码 `5001 AI 服务暂时不可用` |
|
||||
| FR-AI-SUMMARY-BE-13 | 日志记录 | 记录请求耗时、模型名称、token 消耗(若响应中含 `usage` 字段)、是否成功;不记录请求与响应的完整文本,避免日志膨胀 |
|
||||
| FR-AI-SUMMARY-BE-14 | 敏感配置 | `dashscope.api-key` 不允许写入仓库的 `application.yml`,需通过环境变量 `DASHSCOPE_API_KEY` 或本地未提交的 `application-local.yml` 覆盖 |
|
||||
|
||||
#### 3.2.3 响应数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"summary": "本文系统介绍了…(80~200 字)",
|
||||
"model": "qwen-turbo",
|
||||
"promptTokens": 1024,
|
||||
"completionTokens": 96,
|
||||
"costMillis": 1832
|
||||
},
|
||||
"timestamp": 1716268800000
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.4 错误码扩展
|
||||
|
||||
| 错误码 | 描述 | 触发条件 |
|
||||
| :----- | :-------------------- | :--------------------------------------------- |
|
||||
| 5001 | AI 服务暂时不可用 | DashScope HTTP 非 2xx 或返回内容不可解析 |
|
||||
| 5002 | AI 服务响应超时 | DashScope 调用超过 30 秒未返回 |
|
||||
| 5003 | AI 内容审核未通过 | DashScope 返回内容被审核拦截(保留扩展) |
|
||||
|
||||
#### 3.2.5 验收标准
|
||||
|
||||
| 编号 | 验证场景 | 预期结果 |
|
||||
| :------------------- | :-------------------------------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||
| AC-AI-SUMMARY-BE-01 | 未登录调用接口 | 返回 `401 UNAUTHORIZED` |
|
||||
| AC-AI-SUMMARY-BE-02 | 登录用户传入合法正文 | 返回 200,`data.summary` 长度在 80~200 字符之间 |
|
||||
| AC-AI-SUMMARY-BE-03 | 正文 < 20 字符 | 返回 `400 BAD_REQUEST`,提示"正文过短,无法生成摘要" |
|
||||
| AC-AI-SUMMARY-BE-04 | `dashscope.api-key` 未配置 | 返回 `5001`,提示"AI 服务未配置 API Key" |
|
||||
| AC-AI-SUMMARY-BE-05 | DashScope 返回 401(API Key 无效) | 返回 `5001`,记录 ERROR 日志 |
|
||||
| AC-AI-SUMMARY-BE-06 | 单用户 1 分钟内连续调用 6 次 | 第 6 次返回 `429 RATE_LIMITED` |
|
||||
| AC-AI-SUMMARY-BE-07 | 输入正文 9000 字符 | 后端截断至 8000 字符送入模型,正常返回摘要 |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 配置与部署
|
||||
|
||||
#### 3.3.1 配置项清单
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| :----------------------- | :------------------------------------------------------------------ | :-------------------------------------------------------------- |
|
||||
| `dashscope.api-key` | (留空) | 阿里云百炼平台获取的 API Key,**生产必须通过环境变量传入** |
|
||||
| `dashscope.base-url` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | DashScope OpenAI 兼容模式 BaseURL |
|
||||
| `dashscope.model` | `qwen-turbo` | 默认使用的模型 |
|
||||
| `dashscope.timeout` | `30000` | 调用超时(毫秒) |
|
||||
| `dashscope.max-input` | `8000` | 单次输入正文字符上限 |
|
||||
|
||||
#### 3.3.2 环境变量映射
|
||||
|
||||
| 环境变量 | 对应配置项 |
|
||||
| :------------------- | :-------------------- |
|
||||
| `DASHSCOPE_API_KEY` | `dashscope.api-key` |
|
||||
| `DASHSCOPE_BASE_URL` | `dashscope.base-url` |
|
||||
| `DASHSCOPE_MODEL` | `dashscope.model` |
|
||||
|
||||
#### 3.3.3 部署步骤
|
||||
|
||||
1. 在阿里云百炼控制台创建 API Key:<https://bailian.console.aliyun.com/?apiKey=1>
|
||||
2. 启动服务前设置环境变量:
|
||||
```bash
|
||||
export DASHSCOPE_API_KEY=sk-xxxxx
|
||||
```
|
||||
3. 启动 `jog-admin` 服务,访问文章编辑页面,点击"AI 生成摘要"按钮验证
|
||||
|
||||
---
|
||||
|
||||
## 4. 非功能性需求
|
||||
|
||||
| 编号 | 类型 | 需求说明 |
|
||||
| :------ | :-------- | :------------------------------------------------------------------------- |
|
||||
| NFR-01 | 性能 | 95 分位响应时间 ≤ 10 秒(取决于 DashScope 服务,由超时 + 限流保护) |
|
||||
| NFR-02 | 可用性 | DashScope 不可用时,前端仍可手动撰写摘要,业务不受阻塞 |
|
||||
| NFR-03 | 安全性 | API Key 不入库、不入日志、不入仓库;前端不感知 Key 任何信息 |
|
||||
| NFR-04 | 可扩展性 | 后端通过 `AiSummaryService` 抽象,未来可平滑替换为本地模型或其他平台 |
|
||||
| NFR-05 | 可观测性 | 关键节点输出 INFO/ERROR 日志,包含耗时、模型、tokens |
|
||||
|
||||
---
|
||||
|
||||
## 5. 接口与代码对齐
|
||||
|
||||
### 5.1 后端涉及代码
|
||||
|
||||
| 模块 | 文件 | 变更说明 |
|
||||
| :---------------- | :------------------------------------------------------------------------------------ | :------------------------------------ |
|
||||
| jog-module-blog | `dto/GenerateSummaryDTO.java` | 新增请求 DTO |
|
||||
| jog-module-blog | `dto/SummaryResultVO.java` | 新增响应 VO |
|
||||
| jog-module-blog | `config/DashScopeProperties.java` | 新增配置 Properties |
|
||||
| jog-module-blog | `service/AiSummaryService.java` + `service/impl/AiSummaryServiceImpl.java` | 新增摘要服务 |
|
||||
| jog-module-blog | `controller/ArticleController.java` | 新增 `POST /api/app/article/summary` |
|
||||
| jog-admin | `resources/application.yml` | 新增 `dashscope.*` 配置块 |
|
||||
|
||||
### 5.2 前端涉及代码
|
||||
|
||||
| 模块 | 文件 | 变更说明 |
|
||||
| :----------- | :---------------------------------------------------- | :------------------------------------ |
|
||||
| jog-frontend | `src/api/article.js` | 新增 `generateArticleSummary(data)` |
|
||||
| jog-frontend | `src/views/app/ArticleEditor.vue` | 在摘要表单项内新增"AI 生成摘要"按钮 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
| :---------------------------- | :---------------------------------- | :------------------------------------------------------------------ |
|
||||
| DashScope 服务限流或宕机 | 摘要无法生成 | 设置超时与降级,前端允许用户手动撰写 |
|
||||
| API Key 泄露 | 账单异常 | 强制环境变量注入;定期巡检 GitHub/日志中的 Key |
|
||||
| 模型返回内容包含敏感词 | 内容安全风险 | 接入 DashScope 内容审核(后续迭代);保留 `5003` 错误码占位 |
|
||||
| 单次输入过长导致超时 | 用户等待过久 | 后端 8000 字符截断 + 30s 超时熔断 |
|
||||
|
||||
---
|
||||
|
||||
**编写人:** AI 助手
|
||||
**评审人:** 待评审
|
||||
**生效日期:** 2026-05-21
|
||||
@@ -67,6 +67,14 @@ jwt:
|
||||
refresh-token-expire: 86400
|
||||
refresh-token-expire-remember: 1209600
|
||||
|
||||
dashscope:
|
||||
api-key: ${DASHSCOPE_API_KEY:}
|
||||
base-url: ${DASHSCOPE_BASE_URL:https://dashscope.aliyuncs.com/compatible-mode/v1}
|
||||
model: ${DASHSCOPE_MODEL:qwen-turbo}
|
||||
timeout: 30000
|
||||
max-input: 8000
|
||||
rate-limit-per-minute: 5
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
|
||||
@@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS sys_user_session (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
token VARCHAR(500) NOT NULL COMMENT '访问令牌',
|
||||
device_fingerprint VARCHAR(100) DEFAULT NULL COMMENT '设备指纹',
|
||||
device_fingerprint VARCHAR(255) DEFAULT NULL COMMENT '设备指纹',
|
||||
ip_address VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
|
||||
user_agent VARCHAR(500) DEFAULT NULL COMMENT 'User-Agent',
|
||||
expire_time DATETIME NOT NULL COMMENT '过期时间',
|
||||
@@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS sys_login_log (
|
||||
result TINYINT NOT NULL COMMENT '结果:0失败 1成功',
|
||||
fail_reason VARCHAR(200) DEFAULT NULL COMMENT '失败原因',
|
||||
ip_address VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
|
||||
device_fingerprint VARCHAR(100) DEFAULT NULL COMMENT '设备指纹',
|
||||
device_fingerprint VARCHAR(255) DEFAULT NULL COMMENT '设备指纹',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_user_id (user_id),
|
||||
KEY idx_created_at (created_at)
|
||||
|
||||
@@ -46,7 +46,13 @@ public enum ErrorCode {
|
||||
COMMENT_AUDIT_PENDING(3002, "评论待审核"),
|
||||
COMMENT_NO_PERMISSION(3003, "无权操作该评论"),
|
||||
COMMENT_LEVEL_EXCEEDED(3004, "评论层级超限"),
|
||||
COMMENT_LEVEL_LIMIT(3005, "评论层级超限");
|
||||
COMMENT_LEVEL_LIMIT(3005, "评论层级超限"),
|
||||
|
||||
AI_SERVICE_UNAVAILABLE(5001, "AI 服务暂时不可用"),
|
||||
AI_SERVICE_TIMEOUT(5002, "AI 服务响应超时"),
|
||||
AI_CONTENT_REJECTED(5003, "AI 内容审核未通过"),
|
||||
AI_CONTENT_TOO_SHORT(5004, "正文过短,无法生成摘要"),
|
||||
AI_API_KEY_MISSING(5005, "AI 服务未配置 API Key");
|
||||
|
||||
private final int code;
|
||||
private final String message;
|
||||
|
||||
@@ -36,6 +36,10 @@ export function toggleArticleLike(id) {
|
||||
return request.post(`/api/app/article/${id}/like`)
|
||||
}
|
||||
|
||||
export function generateArticleSummary(data) {
|
||||
return request.post('/api/app/article/summary', data, { timeout: 35000 })
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return request.get('/api/public/category/list')
|
||||
}
|
||||
|
||||
@@ -32,7 +32,22 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="form.summary" type="textarea" :rows="2" placeholder="文章摘要" />
|
||||
<div class="summary-wrapper">
|
||||
<el-input v-model="form.summary" type="textarea" :rows="3" placeholder="文章摘要(可手动撰写,也可点击下方按钮由 AI 生成)" />
|
||||
<div class="summary-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="summarizing"
|
||||
@click="handleGenerateSummary"
|
||||
>
|
||||
AI 生成摘要
|
||||
</el-button>
|
||||
<span v-if="lastSummaryMeta" class="summary-meta">
|
||||
{{ lastSummaryMeta }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="可见性">
|
||||
<el-radio-group v-model="form.visibility">
|
||||
@@ -55,13 +70,15 @@ 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 } from '@/api/article'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createArticle, updateArticle, getArticleDetail, getCategories, getTags, generateArticleSummary } from '@/api/article'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const saving = ref(false)
|
||||
const summarizing = ref(false)
|
||||
const lastSummaryMeta = ref('')
|
||||
const categories = ref([])
|
||||
const tags = ref([])
|
||||
const form = ref({
|
||||
@@ -73,6 +90,53 @@ const form = ref({
|
||||
allowComment: true,
|
||||
})
|
||||
|
||||
const MIN_CONTENT_LENGTH = 20
|
||||
|
||||
function getPlainTextContent() {
|
||||
const html = editor.value?.getHTML() || ''
|
||||
const tmp = document.createElement('div')
|
||||
tmp.innerHTML = html
|
||||
return (tmp.textContent || tmp.innerText || '').trim()
|
||||
}
|
||||
|
||||
async function handleGenerateSummary() {
|
||||
const plain = getPlainTextContent()
|
||||
if (plain.length < MIN_CONTENT_LENGTH) {
|
||||
ElMessage.warning('正文过短,无法生成摘要')
|
||||
return
|
||||
}
|
||||
if (form.value.summary && form.value.summary.trim()) {
|
||||
try {
|
||||
await ElMessageBox.confirm('已有摘要,确认覆盖?', '提示', {
|
||||
confirmButtonText: '覆盖',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
summarizing.value = true
|
||||
lastSummaryMeta.value = ''
|
||||
try {
|
||||
const res = await generateArticleSummary({
|
||||
title: form.value.title || '',
|
||||
content: plain,
|
||||
})
|
||||
form.value.summary = res.data.summary
|
||||
const meta = res.data
|
||||
lastSummaryMeta.value = `模型: ${meta.model || '-'} 耗时: ${meta.costMillis ?? '-'}ms` +
|
||||
(meta.promptTokens != null ? ` tokens: ${meta.promptTokens}/${meta.completionTokens}` : '')
|
||||
ElMessage.success('摘要生成成功')
|
||||
} catch (err) {
|
||||
if (err?.code === 'ECONNABORTED') {
|
||||
ElMessage.error('AI 服务响应超时,请稍后再试')
|
||||
}
|
||||
} finally {
|
||||
summarizing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
@@ -193,4 +257,17 @@ onBeforeUnmount(() => {
|
||||
color: var(--text-secondary);
|
||||
margin: var(--spacing-md) 0;
|
||||
}
|
||||
.summary-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
.summary-actions {
|
||||
margin-top: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
.summary-meta {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package fun.nojava.module.blog.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 阿里云百炼(DashScope)大模型平台配置。
|
||||
* <p>
|
||||
* 通过 OpenAI 兼容模式接入,敏感的 {@code apiKey} 必须通过环境变量
|
||||
* {@code DASHSCOPE_API_KEY} 注入,禁止入仓。
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "dashscope")
|
||||
public class DashScopeProperties {
|
||||
|
||||
/** API Key(必填,仅可通过环境变量注入) */
|
||||
private String apiKey = "";
|
||||
|
||||
/** OpenAI 兼容模式 BaseURL */
|
||||
private String baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
|
||||
/** 默认模型 */
|
||||
private String model = "qwen-turbo";
|
||||
|
||||
/** 单次调用超时(毫秒) */
|
||||
private int timeout = 30000;
|
||||
|
||||
/** 单次输入正文字符上限 */
|
||||
private int maxInput = 8000;
|
||||
|
||||
/** 单用户每分钟调用次数上限 */
|
||||
private int rateLimitPerMinute = 5;
|
||||
}
|
||||
+10
@@ -3,6 +3,7 @@ package fun.nojava.module.blog.controller;
|
||||
import fun.nojava.common.model.ApiResult;
|
||||
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 io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
public class ArticleController {
|
||||
|
||||
private final ArticleService articleService;
|
||||
private final AiSummaryService aiSummaryService;
|
||||
|
||||
@Operation(summary = "创建文章")
|
||||
@PostMapping
|
||||
@@ -81,4 +83,12 @@ public class ArticleController {
|
||||
@AuthenticationPrincipal Long userId) {
|
||||
return ApiResult.success(articleService.toggleLike(userId, id));
|
||||
}
|
||||
|
||||
@Operation(summary = "AI 生成文章摘要")
|
||||
@PostMapping("/summary")
|
||||
public ApiResult<SummaryResultVO> generateSummary(
|
||||
@AuthenticationPrincipal Long userId,
|
||||
@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 GenerateSummaryDTO {
|
||||
|
||||
@Size(max = 200, message = "标题最长200字符")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "内容不能为空")
|
||||
@Size(max = 50000, message = "正文过长")
|
||||
private String content;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class SummaryResultVO {
|
||||
|
||||
private String summary;
|
||||
|
||||
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.GenerateSummaryDTO;
|
||||
import fun.nojava.module.blog.dto.SummaryResultVO;
|
||||
|
||||
public interface AiSummaryService {
|
||||
|
||||
SummaryResultVO generateSummary(Long userId, GenerateSummaryDTO dto);
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
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.GenerateSummaryDTO;
|
||||
import fun.nojava.module.blog.dto.SummaryResultVO;
|
||||
import fun.nojava.module.blog.service.AiSummaryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 阿里云百炼(DashScope)OpenAI 兼容模式摘要生成服务。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiSummaryServiceImpl implements AiSummaryService {
|
||||
|
||||
private static final int MIN_CONTENT_LENGTH = 20;
|
||||
private static final int SUMMARY_MAX_LENGTH = 200;
|
||||
private static final String SUMMARY_TRUNCATE_SUFFIX = "…";
|
||||
private static final String RATE_LIMIT_KEY_PREFIX = "ai_summary:rate:";
|
||||
|
||||
private static final String SYSTEM_PROMPT =
|
||||
"你是一名专业的博客编辑,请根据用户提供的标题与正文生成 100~150 字的中文摘要。" +
|
||||
"要求:语言精炼、概括文章核心观点、不使用第一人称、不要出现\"本文\"以外的元描述、" +
|
||||
"直接输出摘要正文,不要加任何前缀或引号。";
|
||||
|
||||
private final DashScopeProperties properties;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
private RestClient restClient;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout((int) Duration.ofMillis(properties.getTimeout()).toMillis());
|
||||
factory.setReadTimeout((int) Duration.ofMillis(properties.getTimeout()).toMillis());
|
||||
this.restClient = RestClient.builder()
|
||||
.baseUrl(properties.getBaseUrl())
|
||||
.requestFactory(factory)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SummaryResultVO generateSummary(Long userId, GenerateSummaryDTO 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.3,
|
||||
"top_p", 0.9
|
||||
);
|
||||
|
||||
Map<String, Object> response;
|
||||
try {
|
||||
response = callModel(requestBody);
|
||||
} catch (ResourceAccessException ex) {
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
log.error("DashScope 调用超时, userId={}, cost={}ms", userId, System.currentTimeMillis() - startMillis);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_TIMEOUT);
|
||||
}
|
||||
log.error("DashScope 网络异常, userId={}, error={}", userId, ex.getMessage());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (RestClientResponseException ex) {
|
||||
log.error("DashScope HTTP 异常, status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (Exception ex) {
|
||||
log.error("DashScope 未知异常, userId={}, error={}", userId, ex.getMessage(), ex);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
long costMillis = System.currentTimeMillis() - startMillis;
|
||||
SummaryResultVO result = parseResponse(response, costMillis);
|
||||
log.info("AI 摘要生成成功 userId={}, model={}, promptTokens={}, completionTokens={}, cost={}ms",
|
||||
userId, result.getModel(), result.getPromptTokens(), result.getCompletionTokens(), costMillis);
|
||||
return result;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
protected Map<String, Object> callModel(Map<String, Object> requestBody) {
|
||||
return restClient.post()
|
||||
.uri("/chat/completions")
|
||||
.header("Authorization", "Bearer " + properties.getApiKey())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(requestBody)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
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("AI 摘要限流命中 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();
|
||||
}
|
||||
|
||||
private SummaryResultVO parseResponse(Map<String, Object> response, long costMillis) {
|
||||
if (response == null) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
|
||||
if (choices == null || choices.isEmpty()) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
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);
|
||||
}
|
||||
|
||||
String summary = truncate(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 SummaryResultVO.builder()
|
||||
.summary(summary)
|
||||
.model(model)
|
||||
.promptTokens(promptTokens)
|
||||
.completionTokens(completionTokens)
|
||||
.costMillis(costMillis)
|
||||
.build();
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.error("解析 DashScope 响应失败 error={}", ex.getMessage(), ex);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private Integer toInt(Object value) {
|
||||
if (value instanceof Number n) {
|
||||
return n.intValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String truncate(String text) {
|
||||
if (text.length() <= SUMMARY_MAX_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, SUMMARY_MAX_LENGTH - 1) + SUMMARY_TRUNCATE_SUFFIX;
|
||||
}
|
||||
|
||||
private String stripHtml(String content) {
|
||||
if (content == null) {
|
||||
return "";
|
||||
}
|
||||
String text = content.replaceAll("(?is)<script[^>]*>.*?</script>", " ")
|
||||
.replaceAll("(?is)<style[^>]*>.*?</style>", " ")
|
||||
.replaceAll("<[^>]+>", " ")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"");
|
||||
return text.replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
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.GenerateSummaryDTO;
|
||||
import fun.nojava.module.blog.dto.SummaryResultVO;
|
||||
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.mockito.Mockito;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AiSummaryServiceImplTest {
|
||||
|
||||
private DashScopeProperties properties;
|
||||
private StringRedisTemplate redisTemplate;
|
||||
private ValueOperations<String, String> valueOps;
|
||||
private TestableAiSummaryService 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);
|
||||
|
||||
service = new TestableAiSummaryService(properties, redisTemplate);
|
||||
service.init();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-01: 正文过短应抛出 AI_CONTENT_TOO_SHORT")
|
||||
void shouldRejectWhenContentTooShort() {
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setTitle("t");
|
||||
dto.setContent("太短了");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_CONTENT_TOO_SHORT.getCode(), ex.getCode());
|
||||
verify(valueOps, never()).increment(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-02: 未配置 API Key 应抛出 AI_API_KEY_MISSING")
|
||||
void shouldRejectWhenApiKeyMissing() {
|
||||
properties.setApiKey("");
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_API_KEY_MISSING.getCode(), ex.getCode());
|
||||
verify(valueOps, never()).increment(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-03: 单用户每分钟超过 5 次应触发 RATE_LIMITED")
|
||||
void shouldThrowRateLimitedWhenExceedsLimit() {
|
||||
when(valueOps.increment("ai_summary:rate:7")).thenReturn(6L);
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(7L, dto));
|
||||
assertEquals(ErrorCode.RATE_LIMITED.getCode(), ex.getCode());
|
||||
service.assertNoCall();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-04: 首次调用应设置 1 分钟 TTL")
|
||||
void shouldExpireRateLimitKeyOnFirstCall() {
|
||||
when(valueOps.increment("ai_summary:rate:8")).thenReturn(1L);
|
||||
service.stubResponse(buildModelResponse("一段满足长度要求的摘要内容,文字精炼概括核心观点。"));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
service.generateSummary(8L, dto);
|
||||
|
||||
verify(redisTemplate, times(1)).expire(eq("ai_summary:rate:8"), eq(1L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-05: 正常返回应填充摘要、模型、tokens、耗时")
|
||||
void shouldReturnSummaryOnSuccess() {
|
||||
service.stubResponse(buildModelResponse("这是一段长度合适的摘要内容,对全文做了精炼概括。"));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setTitle("标题");
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
SummaryResultVO result = service.generateSummary(1L, dto);
|
||||
|
||||
assertNotNull(result.getSummary());
|
||||
assertEquals("qwen-turbo", result.getModel());
|
||||
assertEquals(123, result.getPromptTokens());
|
||||
assertEquals(45, result.getCompletionTokens());
|
||||
assertNotNull(result.getCostMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-06: 正文中的 HTML 标签应被剥离后再送入模型")
|
||||
void shouldStripHtmlBeforeCallingModel() {
|
||||
AtomicReference<Map<String, Object>> captured = new AtomicReference<>();
|
||||
service.stubResponseWithCapture(buildModelResponse("摘要内容长度合规并完整。"), captured::set);
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("<p>这是 <strong>HTML</strong> 富文本内容,足够长以通过长度校验。</p>");
|
||||
|
||||
service.generateSummary(1L, dto);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> messages = (List<Map<String, Object>>) captured.get().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"), "纯文本内容缺失");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-07: 长正文应被截断到 maxInput 上限")
|
||||
void shouldTruncateLongContentToMaxInput() {
|
||||
properties.setMaxInput(50);
|
||||
AtomicReference<Map<String, Object>> captured = new AtomicReference<>();
|
||||
service.stubResponseWithCapture(buildModelResponse("摘要内容长度合规并完整。"), captured::set);
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("A".repeat(500));
|
||||
|
||||
service.generateSummary(1L, dto);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> messages = (List<Map<String, Object>>) captured.get().get("messages");
|
||||
String userContent = (String) messages.get(1).get("content");
|
||||
long aCount = userContent.chars().filter(c -> c == 'A').count();
|
||||
assertEquals(50, aCount, "正文截断到 maxInput 字符未生效");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-08: 模型返回超长内容应截断至 200 字符并加省略号")
|
||||
void shouldTruncateLongModelOutput() {
|
||||
String longSummary = "摘".repeat(300);
|
||||
service.stubResponse(buildModelResponse(longSummary));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
SummaryResultVO result = service.generateSummary(1L, dto);
|
||||
|
||||
assertEquals(200, result.getSummary().length());
|
||||
assertTrue(result.getSummary().endsWith("…"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-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");
|
||||
service.stubResponse(response);
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-SUM-10: SocketTimeoutException 应映射为 AI_SERVICE_TIMEOUT")
|
||||
void shouldMapTimeoutToAiServiceTimeout() {
|
||||
service.stubException(new ResourceAccessException("read timeout",
|
||||
new SocketTimeoutException("Read timed out")));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_TIMEOUT.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-AI-SUM-11: HTTP 401 等错误应映射为 AI_SERVICE_UNAVAILABLE")
|
||||
void shouldMapHttpErrorToAiServiceUnavailable() {
|
||||
service.stubException(Mockito.mock(RestClientResponseException.class));
|
||||
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setContent("这是一篇足够长的文章正文用于触发摘要生成测试用例。");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> service.generateSummary(1L, dto));
|
||||
assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
private Map<String, Object> buildModelResponse(String summary) {
|
||||
return Map.of(
|
||||
"model", "qwen-turbo",
|
||||
"choices", List.of(
|
||||
Map.of("message", Map.of("role", "assistant", "content", summary))
|
||||
),
|
||||
"usage", Map.of(
|
||||
"prompt_tokens", 123,
|
||||
"completion_tokens", 45,
|
||||
"total_tokens", 168
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可观察的 service 子类,劫持 callModel 用于断言传入参数 / 抛出异常。
|
||||
*/
|
||||
private static class TestableAiSummaryService extends AiSummaryServiceImpl {
|
||||
private Map<String, Object> stubbedResponse;
|
||||
private RuntimeException stubbedException;
|
||||
private java.util.function.Consumer<Map<String, Object>> requestCapture;
|
||||
private int callCount = 0;
|
||||
|
||||
TestableAiSummaryService(DashScopeProperties properties, StringRedisTemplate redisTemplate) {
|
||||
super(properties, redisTemplate);
|
||||
}
|
||||
|
||||
void stubResponse(Map<String, Object> response) {
|
||||
this.stubbedResponse = response;
|
||||
}
|
||||
|
||||
void stubResponseWithCapture(Map<String, Object> response,
|
||||
java.util.function.Consumer<Map<String, Object>> capture) {
|
||||
this.stubbedResponse = response;
|
||||
this.requestCapture = capture;
|
||||
}
|
||||
|
||||
void stubException(RuntimeException ex) {
|
||||
this.stubbedException = ex;
|
||||
}
|
||||
|
||||
void assertNoCall() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(0, callCount, "不应调用模型");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, Object> callModel(Map<String, Object> requestBody) {
|
||||
callCount++;
|
||||
if (requestCapture != null) {
|
||||
requestCapture.accept(requestBody);
|
||||
}
|
||||
if (stubbedException != null) {
|
||||
throw stubbedException;
|
||||
}
|
||||
return stubbedResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import fun.nojava.module.blog.config.DashScopeProperties;
|
||||
import fun.nojava.module.blog.dto.GenerateSummaryDTO;
|
||||
import fun.nojava.module.blog.dto.SummaryResultVO;
|
||||
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 java.util.concurrent.TimeUnit;
|
||||
|
||||
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 AiSummaryServiceLiveIT {
|
||||
|
||||
/** 远离任何真实用户 ID 的隔离 userId,确保限流键不会与生产数据冲突 */
|
||||
private static final long FAKE_USER_ID = 9_999_999_999L;
|
||||
|
||||
private DashScopeProperties properties;
|
||||
private StringRedisTemplate redisTemplate;
|
||||
private ValueOperations<String, String> valueOps;
|
||||
private AiSummaryServiceImpl 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);
|
||||
|
||||
service = new AiSummaryServiceImpl(properties, redisTemplate);
|
||||
service.init();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (redisTemplate != null) {
|
||||
try {
|
||||
redisTemplate.delete("ai_summary:rate:" + FAKE_USER_ID);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("live")
|
||||
@DisplayName("TC-AI-SUM-LIVE-01: 真实调用百炼 qwen-turbo 应返回非空摘要")
|
||||
void shouldGenerateRealSummary() {
|
||||
GenerateSummaryDTO dto = new GenerateSummaryDTO();
|
||||
dto.setTitle("Spring Boot 3 入门指南");
|
||||
dto.setContent("Spring Boot 是一个用于简化 Spring 应用开发的开源框架。"
|
||||
+ "它通过自动配置、起步依赖和内嵌服务器等特性,大幅降低了 Spring 项目的搭建成本。"
|
||||
+ "Spring Boot 3 基于 Spring Framework 6 和 Java 17,全面支持 GraalVM Native Image 与 Jakarta EE 9+ 命名空间,"
|
||||
+ "并对响应式编程、可观测性(Micrometer Observation API)做了进一步增强。"
|
||||
+ "本文将从环境搭建、自动配置原理、Starter 工程结构、Actuator 监控、配置中心集成等方面,"
|
||||
+ "系统介绍 Spring Boot 3 的核心特性与最佳实践,帮助读者快速上手并构建生产级应用。");
|
||||
|
||||
SummaryResultVO result = service.generateSummary(FAKE_USER_ID, dto);
|
||||
|
||||
assertNotNull(result, "返回结果不应为 null");
|
||||
assertNotNull(result.getSummary(), "summary 不应为 null");
|
||||
assertFalse(result.getSummary().isBlank(), "summary 不应为空白");
|
||||
assertTrue(result.getSummary().length() >= 30,
|
||||
"摘要应不少于 30 字符(实际:" + result.getSummary().length() + ")");
|
||||
assertTrue(result.getSummary().length() <= 200,
|
||||
"摘要应不超过 200 字符(实际:" + result.getSummary().length() + ")");
|
||||
assertNotNull(result.getModel(), "model 字段不应为 null");
|
||||
assertNotNull(result.getCostMillis(), "costMillis 不应为 null");
|
||||
|
||||
System.out.println("=== Live DashScope 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("summary = " + result.getSummary());
|
||||
System.out.println("===========================");
|
||||
}
|
||||
|
||||
private String envOrDefault(String name, String defaultValue) {
|
||||
String value = System.getenv(name);
|
||||
return (value == null || value.isBlank()) ? defaultValue : value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user