From 14c3f98449fb395a86ba4e26c0bed029e89b9c5c Mon Sep 17 00:00:00 2001 From: nojava Date: Thu, 21 May 2026 16:01:34 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9B=86=E6=88=90AI=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/jog-system-ai-summary-requirements.md | 219 +++++++++++++ jog-admin/src/main/resources/application.yml | 8 + .../db/migration/V1__init_schema.sql | 4 +- .../nojava/common/exception/ErrorCode.java | 8 +- jog-frontend/src/api/article.js | 4 + jog-frontend/src/views/app/ArticleEditor.vue | 83 ++++- .../blog/config/DashScopeProperties.java | 35 +++ .../blog/controller/ArticleController.java | 10 + .../module/blog/dto/GenerateSummaryDTO.java | 16 + .../module/blog/dto/SummaryResultVO.java | 19 ++ .../module/blog/service/AiSummaryService.java | 9 + .../service/impl/AiSummaryServiceImpl.java | 224 ++++++++++++++ .../impl/AiSummaryServiceImplTest.java | 292 ++++++++++++++++++ .../service/impl/AiSummaryServiceLiveIT.java | 110 +++++++ 14 files changed, 1035 insertions(+), 6 deletions(-) create mode 100644 doc/jog-system-ai-summary-requirements.md create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/config/DashScopeProperties.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/GenerateSummaryDTO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/SummaryResultVO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/service/AiSummaryService.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImpl.java create mode 100644 jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImplTest.java create mode 100644 jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiSummaryServiceLiveIT.java diff --git a/doc/jog-system-ai-summary-requirements.md b/doc/jog-system-ai-summary-requirements.md new file mode 100644 index 0000000..2e4c92f --- /dev/null +++ b/doc/jog-system-ai-summary-requirements.md @@ -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 兼容模式:
Endpoint: `https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions`
模型: `qwen-turbo` | +| FR-AI-SUMMARY-BE-06 | 提示词(Prompt) | System:你是一名专业的博客编辑,请根据用户提供的标题与正文生成 100~150 字的中文摘要,要求语言精炼、概括核心观点,不使用第一人称
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: +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 diff --git a/jog-admin/src/main/resources/application.yml b/jog-admin/src/main/resources/application.yml index de663c7..f0f59fa 100644 --- a/jog-admin/src/main/resources/application.yml +++ b/jog-admin/src/main/resources/application.yml @@ -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 diff --git a/jog-admin/src/main/resources/db/migration/V1__init_schema.sql b/jog-admin/src/main/resources/db/migration/V1__init_schema.sql index a55a936..add1b5a 100644 --- a/jog-admin/src/main/resources/db/migration/V1__init_schema.sql +++ b/jog-admin/src/main/resources/db/migration/V1__init_schema.sql @@ -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) diff --git a/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java b/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java index 0f663b4..d5f027c 100644 --- a/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java +++ b/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java @@ -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; diff --git a/jog-frontend/src/api/article.js b/jog-frontend/src/api/article.js index 449cca6..de13012 100644 --- a/jog-frontend/src/api/article.js +++ b/jog-frontend/src/api/article.js @@ -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') } diff --git a/jog-frontend/src/views/app/ArticleEditor.vue b/jog-frontend/src/views/app/ArticleEditor.vue index 4195eef..b5a84d9 100644 --- a/jog-frontend/src/views/app/ArticleEditor.vue +++ b/jog-frontend/src/views/app/ArticleEditor.vue @@ -32,7 +32,22 @@ - +
+ +
+ + AI 生成摘要 + + + {{ lastSummaryMeta }} + +
+
@@ -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); +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/config/DashScopeProperties.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/config/DashScopeProperties.java new file mode 100644 index 0000000..9d74541 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/config/DashScopeProperties.java @@ -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)大模型平台配置。 + *

+ * 通过 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; +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java index 998ea21..ec9939f 100644 --- a/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java @@ -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 generateSummary( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody GenerateSummaryDTO dto) { + return ApiResult.success(aiSummaryService.generateSummary(userId, dto)); + } } diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/GenerateSummaryDTO.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/GenerateSummaryDTO.java new file mode 100644 index 0000000..3a05b88 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/GenerateSummaryDTO.java @@ -0,0 +1,16 @@ +package fun.nojava.module.blog.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class GenerateSummaryDTO { + + @Size(max = 200, message = "标题最长200字符") + private String title; + + @NotBlank(message = "内容不能为空") + @Size(max = 50000, message = "正文过长") + private String content; +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/SummaryResultVO.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/SummaryResultVO.java new file mode 100644 index 0000000..ba7d2b0 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/SummaryResultVO.java @@ -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; +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/service/AiSummaryService.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/AiSummaryService.java new file mode 100644 index 0000000..2261030 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/AiSummaryService.java @@ -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); +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImpl.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImpl.java new file mode 100644 index 0000000..b469a49 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImpl.java @@ -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 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 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 callModel(Map 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 response, long costMillis) { + if (response == null) { + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + try { + @SuppressWarnings("unchecked") + List> choices = (List>) response.get("choices"); + if (choices == null || choices.isEmpty()) { + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + @SuppressWarnings("unchecked") + Map message = (Map) choices.get(0).get("message"); + String content = message == null ? null : (String) message.get("content"); + if (content == null || content.isBlank()) { + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + + 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)]*>.*?", " ") + .replaceAll("(?is)]*>.*?", " ") + .replaceAll("<[^>]+>", " ") + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\""); + return text.replaceAll("\\s+", " ").trim(); + } +} diff --git a/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImplTest.java b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImplTest.java new file mode 100644 index 0000000..858043d --- /dev/null +++ b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImplTest.java @@ -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 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> captured = new AtomicReference<>(); + service.stubResponseWithCapture(buildModelResponse("摘要内容长度合规并完整。"), captured::set); + + GenerateSummaryDTO dto = new GenerateSummaryDTO(); + dto.setContent("

这是 HTML 富文本内容,足够长以通过长度校验。

"); + + service.generateSummary(1L, dto); + + @SuppressWarnings("unchecked") + List> messages = (List>) captured.get().get("messages"); + String userContent = (String) messages.get(1).get("content"); + assertFalse(userContent.contains("

"), "HTML 标签未被剥离: " + userContent); + assertFalse(userContent.contains(""), "HTML 标签未被剥离: " + userContent); + assertTrue(userContent.contains("HTML"), "纯文本内容缺失"); + } + + @Test + @Tag("p0") + @DisplayName("TC-AI-SUM-07: 长正文应被截断到 maxInput 上限") + void shouldTruncateLongContentToMaxInput() { + properties.setMaxInput(50); + AtomicReference> captured = new AtomicReference<>(); + service.stubResponseWithCapture(buildModelResponse("摘要内容长度合规并完整。"), captured::set); + + GenerateSummaryDTO dto = new GenerateSummaryDTO(); + dto.setContent("A".repeat(500)); + + service.generateSummary(1L, dto); + + @SuppressWarnings("unchecked") + List> messages = (List>) 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 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 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 stubbedResponse; + private RuntimeException stubbedException; + private java.util.function.Consumer> requestCapture; + private int callCount = 0; + + TestableAiSummaryService(DashScopeProperties properties, StringRedisTemplate redisTemplate) { + super(properties, redisTemplate); + } + + void stubResponse(Map response) { + this.stubbedResponse = response; + } + + void stubResponseWithCapture(Map response, + java.util.function.Consumer> 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 callModel(Map requestBody) { + callCount++; + if (requestCapture != null) { + requestCapture.accept(requestBody); + } + if (stubbedException != null) { + throw stubbedException; + } + return stubbedResponse; + } + } +} diff --git a/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiSummaryServiceLiveIT.java b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiSummaryServiceLiveIT.java new file mode 100644 index 0000000..c19510b --- /dev/null +++ b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiSummaryServiceLiveIT.java @@ -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)的联调测试。 + *

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

+ * 本测试不操作数据库;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 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; + } +}