From 8b23d025317a4ec54b629ddd35daadcf122bbd9c Mon Sep 17 00:00:00 2001 From: nojava Date: Sat, 23 May 2026 18:48:35 +0800 Subject: [PATCH] =?UTF-8?q?ai=E6=96=87=E7=AB=A0=E6=B6=A6=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/AI文章润色与拼写检查功能需求.md | 258 +++++++++ doc/AI文章润色功能技术实施文档.md | 428 +++++++++++++++ jog-frontend/src/api/article.js | 4 + jog-frontend/src/components.d.ts | 1 + .../src/components/PolishDiffPanel.vue | 182 +++++++ jog-frontend/src/views/app/ArticleEditor.vue | 81 ++- .../blog/controller/ArticleController.java | 10 + .../nojava/module/blog/dto/DiffSegmentVO.java | 15 + .../module/blog/dto/PolishResultVO.java | 25 + .../nojava/module/blog/dto/PolishTextDTO.java | 13 + .../blog/service/TextPolishService.java | 9 + .../service/impl/TextPolishServiceImpl.java | 225 ++++++++ .../impl/TextPolishServiceImplTest.java | 505 ++++++++++++++++++ 13 files changed, 1755 insertions(+), 1 deletion(-) create mode 100644 doc/AI文章润色与拼写检查功能需求.md create mode 100644 doc/AI文章润色功能技术实施文档.md create mode 100644 jog-frontend/src/components/PolishDiffPanel.vue create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/DiffSegmentVO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/PolishResultVO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/PolishTextDTO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/service/TextPolishService.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/TextPolishServiceImpl.java create mode 100644 jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TextPolishServiceImplTest.java diff --git a/doc/AI文章润色与拼写检查功能需求.md b/doc/AI文章润色与拼写检查功能需求.md new file mode 100644 index 0000000..09c7e11 --- /dev/null +++ b/doc/AI文章润色与拼写检查功能需求.md @@ -0,0 +1,258 @@ +# AI 文章润色与拼写检查功能 - 需求规格说明书 + +| 文档版本 | 修订日期 | 修订人 | 修订内容 | +| :------- | :--------- | :----- | :---------------------------------------- | +| V1.0 | 2026-05-23 | — | 初始版本,定义 AI 文章润色功能完整需求 | + +--- + +## 1. 项目概述 + +### 1.1 功能背景 + +在博客写作过程中,创作者常面临语法不通顺、用词不当、标点不规范等问题。手动校对耗时且容易遗漏。为提升写作效率与文章质量,在 Jog 博客系统的文章编辑器中新增 **AI 润色** 功能,一键优化文本表达,并提供原文与润色结果的对比视图,允许用户灵活采纳。 + +### 1.2 功能目标 + +- 降低创作者校对与润色的时间成本 +- 通过 AI 自动优化语法、表达和标点,提升文章专业性 +- 提供原文与润色结果的对比视图,让用户掌控修改粒度 +- 支持段落级润色,避免一次性替换全文带来的风险 +- 复用现有 DashScope 大模型接入能力,降低开发成本 + +### 1.3 适用范围 + +| 角色 | 涉及范围 | +| :------------- | :---------------------------------------------------- | +| 产品经理 | 验证功能形态与对比交互流程 | +| 前端开发工程师 | 实现"AI 润色"按钮、对比视图、采纳/撤销交互 | +| 后端开发工程师 | 新增润色 API,复用 DashScope 调用链路 | +| 测试工程师 | 编写自动化测试与回归用例 | +| 运维工程师 | 无需额外配置(复用已有 `dashscope.*` 配置项) | + +--- + +## 2. 名词解释 + +| 术语 | 说明 | +| :----------------- | :------------------------------------------------------------------------------------- | +| 润色 | 对文本进行语法修正、表达优化、标点规范化,保持原意不变 | +| 对比视图 | 左侧展示原文、右侧展示润色结果的并排布局,方便用户逐段比对 | +| 采纳 | 用户确认使用润色后的文本替换原文 | +| 部分采纳 | 用户仅替换选定段落,而非全文替换 | +| DashScope | 阿里云百炼对外暴露的 API 服务名,本系统对接其 OpenAI 兼容模式 | +| Qwen-Turbo | 通义千问 Turbo 系列模型,速度快、成本低,适合文本润色等任务 | + +--- + +## 3. 功能需求清单 + +### 3.1 入口与按钮交互(前端) + +#### 3.1.1 功能描述 + +在文章编辑器页面(`/app/editor` 与 `/app/editor/:id`)的富文本工具栏右侧新增 **"AI 润色"** 按钮。用户选中需要润色的文本段落后,点击按钮调用后端润色接口,系统展示原文与润色结果的对比视图供用户决策。 + +#### 3.1.2 业务规则 + +| 编号 | 规则项 | 业务规则说明 | +| :-------------------- | :--------------- | :---------------------------------------------------------------------------------------------------------------------- | +| FR-AI-POLISH-FE-01 | 按钮位置 | 按钮显示在富文本编辑器工具栏右侧,文案为"AI 润色",可配图标(如钢笔图标),使用 Element Plus `type="warning"` 区分现有按钮 | +| FR-AI-POLISH-FE-02 | 触发前置条件 | 用户需在编辑器中选中文本(纯文本长度 ≥ 20 字符);未选中或选中过短时提示"请先选中至少 20 个字符的文本" | +| FR-AI-POLISH-FE-03 | Loading 状态 | 按钮在请求期间显示 `loading` 态并禁用,防止重复点击 | +| FR-AI-POLISH-FE-04 | 对比视图 | 润色完成后,在编辑器下方弹出对比面板:左侧为"原文"(带浅红背景)、右侧为"润色后"(带浅绿背景),逐段展示差异 | +| FR-AI-POLISH-FE-05 | 段落级操作 | 每段对比行提供"采纳"按钮,用户可逐段替换;面板顶部提供"全部采纳"按钮,一键替换所有段落 | +| FR-AI-POLISH-FE-06 | 采纳行为 | 点击"采纳",将该段润色文本替换回编辑器对应位置;替换后该段高亮闪烁一次以提示用户 | +| FR-AI-POLISH-FE-07 | 关闭对比视图 | 提供"关闭"按钮关闭对比面板;关闭前若有未采纳段落,弹出确认"有未采纳的润色结果,确定关闭?" | +| FR-AI-POLISH-FE-08 | 失败提示 | 接口返回失败时,提示后端返回的 `message`,按钮恢复可点击状态 | +| FR-AI-POLISH-FE-09 | 超时提示 | 请求超过 30 秒未返回,提示"AI 服务响应超时,请稍后再试" | +| FR-AI-POLISH-FE-10 | 多次润色 | 用户可对同一段落多次润色;对比视图始终展示当前原文与最新润色结果的对比 | + +#### 3.1.3 验收标准 + +| 编号 | 验证场景 | 预期结果 | +| :-------------------- | :---------------------------------------------------------- | :-------------------------------------------------------------------------------- | +| AC-AI-POLISH-FE-01 | 未选中文本时点击"AI 润色" | 提示"请先选中至少 20 个字符的文本",不发起后端请求 | +| AC-AI-POLISH-FE-02 | 选中 ≥ 20 字符文本,点击按钮 | 按钮进入 loading 态,请求成功后显示对比视图 | +| AC-AI-POLISH-FE-03 | 对比视图中点击单段"采纳" | 该段原文被润色文本替换,编辑器光标跳转到替换位置 | +| AC-AI-POLISH-FE-04 | 对比视图中点击"全部采纳" | 所有段落被替换,面板自动关闭,提示"已全部采纳" | +| AC-AI-POLISH-FE-05 | 有未采纳段落时点击关闭 | 弹出确认对话框;取消则保持面板;确认则关闭面板 | +| AC-AI-POLISH-FE-06 | 后端返回 5001 错误 | 提示"AI 服务暂时不可用,请稍后再试",按钮恢复可用 | +| AC-AI-POLISH-FE-07 | 在编辑文章(带 `:id`)页面同样可使用润色 | 行为与新建文章一致 | + +--- + +### 3.2 AI 润色接口(后端) + +#### 3.2.1 功能描述 + +后端新增 **`POST /api/app/article/polish`** 接口,接收用户选中的文本段落,调用阿里云百炼大模型进行润色后返回润色结果。 + +#### 3.2.2 业务规则 + +| 编号 | 规则项 | 业务规则说明 | +| :-------------------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ | +| FR-AI-POLISH-BE-01 | 接口路径与方法 | `POST /api/app/article/polish`,需登录用户调用(继承 `/api/app/**` 的 `USER`/`ADMIN` 权限校验) | +| FR-AI-POLISH-BE-02 | 请求参数 | `text`(必填,最长 10000,为去除 HTML 标签后的纯文本选中内容) | +| FR-AI-POLISH-BE-03 | 入参校验 | `text` 为空或去除 HTML 后字符数 < 20,返回 `400 BAD_REQUEST` 与提示"文本过短,无法润色" | +| FR-AI-POLISH-BE-04 | 内容截断 | 若 `text` 字符数 > 6000,仅取前 6000 字符送入大模型,避免超过模型输入限制 | +| FR-AI-POLISH-BE-05 | 模型与平台 | 复用现有 DashScope OpenAI 兼容模式调用,默认使用 `qwen-turbo` | +| FR-AI-POLISH-BE-06 | 提示词(Prompt) | System:你是一名专业的文字编辑,请润色用户提供的中文文本。修正语法错误、优化表达方式、规范标点符号,保持原意不变,使文本更流畅专业。直接输出润色后的文本,不要添加任何解释或标记。
User:请润色下面这段中文文本:{用户文本} | +| FR-AI-POLISH-BE-07 | 段落保持 | 润色后的文本应保持与原文相同的段落结构;若模型合并了段落,后端按原文段落数拆分后返回 | +| FR-AI-POLISH-BE-08 | 鉴权方式 | 复用 `dashscope.api-key`,通过 `Authorization: Bearer {key}` 传递 | +| FR-AI-POLISH-BE-09 | 超时与重试 | 单次调用超时 30 秒,不做自动重试;超时返回 `5002` 错误码 | +| FR-AI-POLISH-BE-10 | 限流 | 复用现有 Redis 限流机制,同一用户每分钟最多调用 5 次(与摘要、标签推荐共享配额);超出返回 `429 RATE_LIMITED` | +| FR-AI-POLISH-BE-11 | 异常处理 | DashScope 返回非 2xx 或 JSON 结构异常时,统一抛出 `BusinessException`,错误码 `5001 AI 服务暂时不可用` | +| FR-AI-POLISH-BE-12 | 日志记录 | 记录请求耗时、文本长度、是否成功;不记录请求与响应的完整文本,避免日志膨胀 | + +#### 3.2.3 响应数据结构 + +```json +{ + "code": 200, + "message": "success", + "data": { + "original": "这是需要润色的原文文本。", + "polished": "这是经过润色的原文文本。", + "diffSegments": [ + { + "index": 0, + "original": "这是需要润色的原文文本。", + "polished": "这是经过润色的原文文本。" + } + ], + "model": "qwen-turbo", + "promptTokens": 512, + "completionTokens": 256, + "costMillis": 1420 + }, + "timestamp": 1716268800000 +} +``` + +| 字段 | 类型 | 说明 | +| :----------------- | :------------ | :------------------------------------------------ | +| original | String | 用户提交的原始文本 | +| polished | String | 润色后的完整文本 | +| diffSegments | Array | 逐段对比数据,前端据此渲染对比视图 | +| diffSegments[].index | int | 段落序号(从 0 开始) | +| diffSegments[].original | String | 该段原文 | +| diffSegments[].polished | String | 该段润色后文本 | +| model | String | 实际使用的模型名称 | +| promptTokens | Integer | 输入消耗的 token 数(可为 null) | +| completionTokens | Integer | 输出消耗的 token 数(可为 null) | +| costMillis | Long | 接口耗时(毫秒) | + +#### 3.2.4 验收标准 + +| 编号 | 验证场景 | 预期结果 | +| :-------------------- | :---------------------------------------------------------- | :-------------------------------------------------------------------------------- | +| AC-AI-POLISH-BE-01 | 未登录调用接口 | 返回 `401 UNAUTHORIZED` | +| AC-AI-POLISH-BE-02 | 登录用户传入合法文本 | 返回 200,`data.polished` 为润色后文本,`diffSegments` 数组非空 | +| AC-AI-POLISH-BE-03 | 文本 < 20 字符 | 返回 `400 BAD_REQUEST`,提示"文本过短,无法润色" | +| AC-AI-POLISH-BE-04 | `dashscope.api-key` 未配置 | 返回 `5005`,提示"AI 服务未配置 API Key" | +| AC-AI-POLISH-BE-05 | DashScope 返回 401(API Key 无效) | 返回 `5001`,记录 ERROR 日志 | +| AC-AI-POLISH-BE-06 | 单用户 1 分钟内连续调用 6 次 | 第 6 次返回 `429 RATE_LIMITED` | + +--- + +### 3.3 对比视图交互设计 + +#### 3.3.1 组件结构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 工具栏 [B][I][H2][H3][列表][引用][分割线] [✨ AI 润色] │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ [富文本编辑器内容区] │ +│ │ +├─────────────────────────────────────────────────────────┤ +│ AI 润色结果对比 [全部采纳] [✕] │ +│ ┌─────────────────────┬─────────────────────────────┐ │ +│ │ 原文 │ 润色后 │ │ +│ │ 这是一段需要润色的 │ 这是一段经过润色的文本。 │ │ +│ │ 文本。 │ [采纳] │ │ +│ ├─────────────────────┼─────────────────────────────┤ │ +│ │ 另一段原文... │ 另一段润色后... [采纳] │ │ +│ └─────────────────────┴─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +#### 3.3.2 交互规则 + +| 编号 | 规则项 | 说明 | +| :-------------------- | :--------------- | :---------------------------------------------------------------------- | +| FR-AI-POLISH-UI-01 | 差异高亮 | 原文与润色后之间的差异字符以不同颜色高亮(红色删除线 = 原文被删,绿色下划线 = 新增) | +| FR-AI-POLISH-UI-02 | 段落边框 | 已采纳的段落行变为灰色并禁用"采纳"按钮,显示"已采纳"标记 | +| FR-AI-POLISH-UI-03 | 全部采纳 | 点击"全部采纳"后,面板显示"全部已采纳"状态 2 秒后自动关闭 | +| FR-AI-POLISH-UI-04 | 快捷键 | `Escape` 键关闭对比面板(触发表 FR-AI-POLISH-FE-07 的确认逻辑) | +| FR-AI-POLISH-UI-05 | 空状态 | 若润色后文本与原文完全一致(无优化空间),展示提示"未发现需要润色的内容" | + +--- + +## 4. 交互流程 + +``` +1. 用户在编辑器中选中一段文本 +2. 用户点击工具栏"AI 润色"按钮 +3. 系统校验选中文本长度(≥ 20 字符) +4. 按钮进入 loading 态,调用后端润色 API +5. API 返回后,编辑器下方弹出对比视图 +6. 用户查看逐段差异: + a. 点击某段"采纳" → 该段原文被润色文本替换 + b. 点击"全部采纳" → 全文替换,面板关闭 + c. 点击"关闭" → 若有未采纳段落,确认后关闭 +7. 完成后,用户可继续编辑或保存文章 +``` + +--- + +## 5. 接口与代码对齐 + +### 5.1 后端涉及代码 + +| 模块 | 文件 | 变更说明 | +| :-------------- | :---------------------------------------------------------------- | :-------------------------------------------- | +| jog-module-blog | `dto/PolishTextDTO.java` | 新增请求 DTO(`text` 字段 + `@NotBlank`) | +| jog-module-blog | `dto/PolishResultVO.java` | 新增响应 VO(含 `diffSegments` 对比数组) | +| jog-module-blog | `dto/DiffSegmentVO.java` | 新增段对比 VO(`index`、`original`、`polished`)| +| jog-module-blog | `service/TextPolishService.java` + `service/impl/TextPolishServiceImpl.java` | 新增润色服务 | +| jog-module-blog | `controller/ArticleController.java` | 新增 `POST /api/app/article/polish` | + +### 5.2 前端涉及代码 + +| 模块 | 文件 | 变更说明 | +| :----------- | :------------------------------------ | :---------------------------------------------------- | +| jog-frontend | `src/api/article.js` | 新增 `polishArticleText(data)` | +| jog-frontend | `src/views/app/ArticleEditor.vue` | 工具栏新增"AI 润色"按钮、新增对比面板组件 | +| jog-frontend | `src/components/PolishDiffPanel.vue` | 新增对比面板组件(原文 vs 润色后,段落级采纳) | + +--- + +## 6. 非功能性需求 + +| 编号 | 类型 | 需求说明 | +| :------ | :-------- | :------------------------------------------------------------------------- | +| NFR-01 | 性能 | 95 分位响应时间 ≤ 10 秒(取决于 DashScope 服务,由超时 + 限流保护) | +| NFR-02 | 可用性 | DashScope 不可用时,用户仍可手动编辑文本,业务不受阻塞 | +| NFR-03 | 安全性 | API Key 不入库、不入日志、不入仓库;前端不感知 Key 任何信息 | +| NFR-04 | 可扩展性 | 后端通过 `TextPolishService` 接口抽象,未来可扩展拼写检查等独立功能 | +| NFR-05 | 可观测性 | 关键节点输出 INFO/ERROR 日志,包含耗时、文本长度 | + +--- + +## 7. 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +| :---------------------------- | :---------------------------------- | :------------------------------------------------------------------ | +| 模型输出偏离原意 | 润色结果不可用 | 提示词强调"保持原意不变";对比视图让用户逐段审核 | +| DashScope 服务限流或宕机 | 润色不可用 | 设置超时与降级,前端允许用户手动编辑 | +| 模型返回格式异常 | 对比视图渲染失败 | 后端对返回结果做段落对齐校验,失败时降级为全文对比 | +| 大量用户同时调用 | 超出 API 配额 | Redis 限流每分钟 5 次/用户 | + +--- + +**编写人:** AI 助手 +**评审人:** 待评审 +**生效日期:** 2026-05-23 diff --git a/doc/AI文章润色功能技术实施文档.md b/doc/AI文章润色功能技术实施文档.md new file mode 100644 index 0000000..722af90 --- /dev/null +++ b/doc/AI文章润色功能技术实施文档.md @@ -0,0 +1,428 @@ +# AI 文章润色功能技术实施文档 + +## 前提条件 + +**数据安全规则**: +- 联调和测试过程中如需创建测试数据,测试完成后必须删除 +- **数据库中存在一篇名为 "hello" 的文章,此文章不允许任何修改或删除操作** + +--- + +## 1. 功能概述 + +在博客后台编辑器中增加"AI 润色"按钮,用户选中文本后调用大模型优化语法、表达和标点,返回结果以对比视图展示,支持逐段采纳或全部采纳。 + +## 2. 核心实现思路 + +### 2.1 复用现有 AI 能力 + +项目已有 `AiSummaryServiceImpl` 调用阿里云 DashScope 模型的能力,润色功能将**复用**该服务的模型调用链路: + +- 复用 `DashScopeProperties` 配置(API Key、Base URL、模型、超时、限流) +- 复用 `AiSummaryServiceImpl.callModel()` 方法(HTTP 请求、认证、错误处理) +- 复用 Redis Rate Limit 限流机制 + +### 2.2 差异化实现 + +与摘要生成、标签推荐的主要差异: + +| 特性 | 摘要生成 | 标签推荐 | 文章润色 | +|------|----------|----------|----------| +| System Prompt | 生成摘要 | 提取标签 | 润色文本 | +| User Prompt | 标题 + 正文 | 标题 + 正文 | 纯文本段落 | +| 输入 | 全文(≤8000字符) | 全文(≤8000字符) | 选中文本(≤6000字符) | +| 响应解析 | 截取摘要文本 | 解析逗号分隔标签 | 段落拆分 + 对比 | +| 返回格式 | SummaryResultVO | TagRecommendResultVO | PolishResultVO(含 diffSegments) | +| 前端交互 | 回填输入框 | 勾选标签确认 | 对比视图逐段采纳 | + +### 2.3 实现方案 + +**后端**:新建 `TextPolishService` 服务类,调用 `AiSummaryServiceImpl.callModel()`,对返回结果做段落对齐拆分,返回 `diffSegments` 数组。 + +**前端**:在富文本工具栏增加"AI 润色"按钮,新建 `PolishDiffPanel.vue` 对比面板组件,实现逐段采纳交互。 + +--- + +## 3. 改动项清单 + +### 3.1 后端改动 + +#### 3.1.1 新增 DTO + +| 文件 | 说明 | +|------|------| +| `dto/PolishTextDTO.java` | 请求 DTO,包含 `text` 字段(@NotBlank,最长10000) | + +```java +// dto/PolishTextDTO.java +@Data +public class PolishTextDTO { + @NotBlank(message = "文本不能为空") + @Size(max = 10000, message = "文本长度不能超过10000") + private String text; +} +``` + +#### 3.1.2 新增 VO + +| 文件 | 说明 | +|------|------| +| `dto/DiffSegmentVO.java` | 段落对比 VO,包含 `index`、`original`、`polished` | +| `dto/PolishResultVO.java` | 返回 VO,包含 `original`、`polished`、`diffSegments`、`model`、`costMillis` 等 | + +```java +// dto/DiffSegmentVO.java +@Data +@Builder +public class DiffSegmentVO { + private int index; + private String original; + private String polished; +} +``` + +```java +// dto/PolishResultVO.java +@Data +@Builder +public class PolishResultVO { + private String original; + private String polished; + private List diffSegments; + private String model; + private Integer promptTokens; + private Integer completionTokens; + private long costMillis; +} +``` + +#### 3.1.3 新增服务接口与实现 + +| 文件 | 说明 | +|------|------| +| `service/TextPolishService.java` | 润色服务接口 | +| `service/impl/TextPolishServiceImpl.java` | 调用 AiSummaryServiceImpl.callModel(),实现润色 + 段落对齐拆分 | + +#### 3.1.4 修改 Controller + +| 文件 | 变更说明 | +|------|----------| +| `controller/ArticleController.java` | 新增 `POST /api/app/article/polish` 接口,注入 `TextPolishService` | + +#### 3.1.5 错误码 + +复用现有错误码,无需新增: + +| 错误码 | 常量名 | 触发场景 | +|--------|--------|----------| +| 5004 | AI_CONTENT_TOO_SHORT | text < 20 字符 | +| 5005 | AI_API_KEY_MISSING | api-key 未配置 | +| 5001 | AI_SERVICE_UNAVAILABLE | DashScope 返回异常 | +| 5002 | AI_SERVICE_TIMEOUT | 调用超时 | +| 429 | RATE_LIMITED | 超出频率限制 | + +--- + +### 3.2 前端改动 + +#### 3.2.1 新增 API 方法 + +| 文件 | 说明 | +|------|------| +| `src/api/article.js` | 新增 `polishArticleText(data)` 方法 | + +```js +export function polishArticleText(data) { + return request.post('/api/app/article/polish', data, { timeout: 35000 }) +} +``` + +#### 3.2.2 修改文章编辑器 + +| 文件 | 说明 | +|------|------| +| `src/views/app/ArticleEditor.vue` | 工具栏新增"AI 润色"按钮、引入对比面板、选中文本获取逻辑 | + +#### 3.2.3 新增对比面板组件 + +| 文件 | 说明 | +|------|------| +| `src/components/PolishDiffPanel.vue` | 原文 vs 润色后对比面板,支持逐段采纳 / 全部采纳 / 关闭 | + +--- + +## 4. 核心逻辑说明 + +### 4.1 后端核心逻辑 + +```java +// TextPolishServiceImpl.java + +// 专用提示词 +private static final String SYSTEM_PROMPT = + "你是一名专业的文字编辑,请润色用户提供的中文文本。" + + "修正语法错误、优化表达方式、规范标点符号,保持原意不变,使文本更流畅专业。" + + "直接输出润色后的文本,不要添加任何解释或标记。"; + +private static final int POLISH_MIN_LENGTH = 20; +private static final int POLISH_MAX_INPUT = 6000; +private static final String RATE_LIMIT_KEY_PREFIX = "text_polish:rate:"; + +// 1. 构建请求 +Map requestBody = Map.of( + "model", properties.getModel(), + "messages", List.of( + Map.of("role", "system", "content", SYSTEM_PROMPT), + Map.of("role", "user", "content", "请润色下面这段中文文本:\n" + plainText) + ), + "temperature", 0.3 // 低温度保证输出稳定 +); + +// 2. 调用模型(复用 AiSummaryServiceImpl.callModel) +Map response = aiSummaryService.callModel(requestBody); + +// 3. 段落对齐:按原文换行符拆分,将润色结果对齐到对应段落 +List segments = alignParagraphs(originalText, polishedText); + +// 4. 返回对比结果 +return PolishResultVO.builder() + .original(originalText) + .polished(polishedText) + .diffSegments(segments) + .model(model) + .costMillis(costMillis) + .build(); +``` + +### 4.2 段落对齐逻辑 + +模型可能合并或拆分段落,需要做对齐处理: + +``` +输入:原文按 \n\n 拆分为段落数组 [P1, P2, P3] +输出:润色文本按 \n\n 拆分 + +对齐策略: +- 如果段落数相等 → 一一对应 +- 如果润色段落多 → 按顺序匹配,多余段落合并到最后一段 +- 如果润色段落少 → 原文多余段落合并,原文和润色文本保持相同的 diffSegments 长度 +- 段数差异过大(>2倍) → 降级为全文对比(单个 diffSegment) +``` + +### 4.3 前端核心逻辑 + +#### 4.3.1 编辑器工具栏新增按钮 + +```vue + +
+ + + +
+ + ✨ AI 润色 + +
+
+``` + +#### 4.3.2 获取选中文本 + +```js +// 通过 Tiptap editor 获取选中文本 +const handlePolishText = () => { + const { from, to } = editor.state.selection + const selectedText = editor.state.doc.textBetween(from, to, '\n') + + if (!selectedText || selectedText.trim().length < 20) { + ElMessage.warning('请先选中至少 20 个字符的文本') + return + } + + polishing.value = true + polishArticleText({ text: selectedText }) + .then(res => { + polishResult.value = res.data.data + showDiffPanel.value = true + }) + .catch(err => { + ElMessage.error(err.response?.data?.message || '润色失败') + }) + .finally(() => { + polishing.value = false + }) +} +``` + +#### 4.3.3 对比面板组件 + +```vue + + + + +``` + +#### 4.3.4 采纳替换逻辑 + +```js +// ArticleEditor.vue 中处理采纳 +const handleAcceptSegment = (index, polishedText) => { + // 通过 Tiptap editor 替换对应段落 + const { from, to } = polishSelection.value // 保存的选中范围 + // 按段落拆分原文,定位到第 index 段在文档中的位置 + // 使用 editor.chain().setTextSelection(...).insertContent(polishedText).run() +} + +const handleAcceptAll = () => { + // 用完整润色文本替换整个选中区域 + editor.chain() + .setTextSelection(polishSelection.value) + .insertContent(polishResult.value.polished) + .run() + showDiffPanel.value = false + ElMessage.success('已全部采纳') +} +``` + +--- + +## 5. 控件说明 + +### 5.1 编辑器选中文本处理 + +由于编辑器使用 Tiptap(富文本),选中的内容可能是带格式的 HTML。润色处理流程: + +``` +1. editor.state.selection → 获取选中范围 { from, to } +2. editor.state.doc.textBetween(from, to, '\n') → 提取纯文本(保留段落分隔) +3. 提交纯文本到后端 → 后端调用 AI 润色 +4. 前端接收 diffSegments → 渲染对比视图 +5. 用户采纳时 → 用润色后纯文本替换选中区域(保留原格式较复杂,首版用纯文本替换) +``` + +### 5.2 对比面板布局 + +``` +┌───────────────────────────────────────────────────────────┐ +│ AI 润色结果对比 [全部采纳] [✕] │ +├───────────────────────────────────────────────────────────┤ +│ ┌──────────────┬──────────────┬──────┐ │ +│ │ 原文 │ 润色后 │ 操作 │ │ +│ ├──────────────┼──────────────┼──────┤ │ +│ │ 原文段落1 │ 润色段落1 │ 采纳 │ │ +│ ├──────────────┼──────────────┼──────┤ │ +│ │ 原文段落2 │ 润色段落2 │ 已采纳│ │ +│ └──────────────┴──────────────┴──────┘ │ +└───────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. 待办事项 + +| 序号 | 待办项 | 状态 | +|------|--------|------| +| 1 | 后端:创建 PolishTextDTO.java | ⬜ 待开始 | +| 2 | 后端:创建 DiffSegmentVO.java | ⬜ 待开始 | +| 3 | 后端:创建 PolishResultVO.java | ⬜ 待开始 | +| 4 | 后端:创建 TextPolishService 接口 | ⬜ 待开始 | +| 5 | 后端:创建 TextPolishServiceImpl 实现类 | ⬜ 待开始 | +| 6 | 后端:修改 AiSummaryServiceImpl,提升 callModel 为 public | ⬜ 待开始 | +| 7 | 后端:在 ArticleController 添加 POST /api/app/article/polish | ⬜ 待开始 | +| 8 | 后端:单元测试(TextPolishServiceImplTest) | ⬜ 待开始 | +| 9 | 前端:在 article.js 添加 polishArticleText API 方法 | ⬜ 待开始 | +| 10 | 前端:创建 PolishDiffPanel.vue 对比面板组件 | ⬜ 待开始 | +| 11 | 前端:在 ArticleEditor.vue 添加"AI 润色"按钮和交互 | ⬜ 待开始 | +| 12 | 前后端联调 + 功能验证 | ⬜ 待开始 | + +--- + +## 7. 实施顺序建议 + +1. **后端子步骤**:调整 `AiSummaryServiceImpl.callModel` 可见性(private → public)→ PolishTextDTO → DiffSegmentVO → PolishResultVO → TextPolishService 接口 → TextPolishServiceImpl → Controller 接口 → 单元测试 +2. **前端子步骤**:API 方法 → PolishDiffPanel 组件 → ArticleEditor 按钮和交互 +3. **最后联调**:前后端对接、bug 修复、验收 + +--- + +## 8. 风险与注意事项 + +1. **callModel 可见性**:`AiSummaryServiceImpl.callModel()` 当前为 `protected`,需改为 `public` 以便 `TextPolishServiceImpl` 调用;或改为通过 `AiSummaryService` 接口暴露(推荐后者以保持封装) +2. **段落对齐准确性**:模型可能重组段落结构,对齐逻辑需覆盖段数不匹配的降级场景,建议首版实现简单策略(按 \n\n 拆分 + 全文降级) +3. **选中文本格式丢失**:采纳润色结果时,原选中区域的富文本格式(加粗、斜体等)会丢失,替换为纯文本。后续版本可考虑保留格式 +4. **限流共享**:润色与摘要、标签推荐共享同一 Rate Limit 配额(每分钟 5 次/用户),高频使用场景需评估是否独立配额 +5. **模型温度设置**:润色功能使用 `temperature=0.3`(与摘要一致),保证输出稳定;若模型输出过于保守可适当调高 +6. **超长文本**:后端截断至 6000 字符,前端应在选中文本过长时给出提示 diff --git a/jog-frontend/src/api/article.js b/jog-frontend/src/api/article.js index 9e4ee37..c7fec80 100644 --- a/jog-frontend/src/api/article.js +++ b/jog-frontend/src/api/article.js @@ -44,6 +44,10 @@ export function recommendTags(data) { return request.post('/api/app/article/recommend-tags', data, { timeout: 35000 }) } +export function polishArticleText(data) { + return request.post('/api/app/article/polish', data, { timeout: 35000 }) +} + export function getCategories() { return request.get('/api/public/category/list') } diff --git a/jog-frontend/src/components.d.ts b/jog-frontend/src/components.d.ts index 63a9e29..1646a0f 100644 --- a/jog-frontend/src/components.d.ts +++ b/jog-frontend/src/components.d.ts @@ -45,6 +45,7 @@ declare module 'vue' { ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] + PolishDiffPanel: typeof import('./components/PolishDiffPanel.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/jog-frontend/src/components/PolishDiffPanel.vue b/jog-frontend/src/components/PolishDiffPanel.vue new file mode 100644 index 0000000..fa87331 --- /dev/null +++ b/jog-frontend/src/components/PolishDiffPanel.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/jog-frontend/src/views/app/ArticleEditor.vue b/jog-frontend/src/views/app/ArticleEditor.vue index 588925a..a741400 100644 --- a/jog-frontend/src/views/app/ArticleEditor.vue +++ b/jog-frontend/src/views/app/ArticleEditor.vue @@ -50,8 +50,24 @@ 引用 分割线 + + AI 润色 + + @@ -93,8 +109,9 @@ import { useRoute, useRouter } from 'vue-router' import { useEditor, EditorContent } from '@tiptap/vue-3' import StarterKit from '@tiptap/starter-kit' import Placeholder from '@tiptap/extension-placeholder' -import { createArticle, updateArticle, getArticleDetail, getCategories, getTags, generateArticleSummary, recommendTags, createAppTag } from '@/api/article' +import { createArticle, updateArticle, getArticleDetail, getCategories, getTags, generateArticleSummary, recommendTags, createAppTag, polishArticleText } from '@/api/article' import { ElMessage, ElMessageBox } from 'element-plus' +import PolishDiffPanel from '@/components/PolishDiffPanel.vue' const route = useRoute() const router = useRouter() @@ -105,6 +122,10 @@ const lastSummaryMeta = ref('') const recommending = ref(false) const recommendedTags = ref([]) const selectedRecommendedTags = ref([]) +const polishing = ref(false) +const polishResult = ref(null) +const showDiffPanel = ref(false) +const polishSelection = ref(null) const categories = ref([]) const tags = ref([]) const form = ref({ @@ -240,6 +261,58 @@ function clearRecommendedTags() { selectedRecommendedTags.value = [] } +async function handlePolishText() { + if (!editor.value) return + const { from, to } = editor.value.state.selection + if (from === to) { + ElMessage.warning('请先选中需要润色的文本') + return + } + const selectedText = editor.value.state.doc.textBetween(from, to, '\n') + if (!selectedText || selectedText.trim().length < MIN_CONTENT_LENGTH) { + ElMessage.warning('请先选中至少 20 个字符的文本') + return + } + polishing.value = true + polishResult.value = null + showDiffPanel.value = false + try { + const res = await polishArticleText({ text: selectedText }) + polishResult.value = res.data + polishSelection.value = { from, to } + showDiffPanel.value = true + } catch (err) { + if (err?.code === 'ECONNABORTED') { + ElMessage.error('AI 服务响应超时,请稍后再试') + } + } finally { + polishing.value = false + } +} + +function handleAcceptSegment(index, polishedText) { + if (!editor.value || !polishSelection.value) return + // 按段落定位替换位置:从原始选中范围的上下文中定位 + const segs = polishResult.value?.diffSegments + if (!segs) return + // 全文替换:直接用润色后完整文本替换整个选中区域 + editor.value.chain() + .setTextSelection(polishSelection.value) + .insertContent(polishedText) + .run() +} + +function handleAcceptAll() { + if (!editor.value || !polishSelection.value || !polishResult.value) return + editor.value.chain() + .setTextSelection(polishSelection.value) + .insertContent(polishResult.value.polished) + .run() + showDiffPanel.value = false + polishResult.value = null + ElMessage.success('已全部采纳') +} + const editor = useEditor({ extensions: [ StarterKit, @@ -332,6 +405,12 @@ onBeforeUnmount(() => { padding: var(--spacing-sm) var(--spacing-md); border-bottom: 1px solid var(--border-color-light); background: var(--bg-soft); + display: flex; + justify-content: space-between; + align-items: center; +} +.polish-btn { + flex-shrink: 0; } .editor-content { min-height: 400px; padding: var(--spacing-lg); } .editor-content :deep(.tiptap) { 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 66baa39..0249ce0 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 @@ -6,6 +6,7 @@ import fun.nojava.module.blog.dto.*; import fun.nojava.module.blog.service.AiSummaryService; import fun.nojava.module.blog.service.ArticleService; import fun.nojava.module.blog.service.TagRecommendService; +import fun.nojava.module.blog.service.TextPolishService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -25,6 +26,7 @@ public class ArticleController { private final ArticleService articleService; private final AiSummaryService aiSummaryService; private final TagRecommendService tagRecommendService; + private final TextPolishService textPolishService; @Operation(summary = "创建文章") @PostMapping @@ -105,4 +107,12 @@ public class ArticleController { return ApiResult.success(aiSummaryService.generateSummary(userId, dto)); } + @Operation(summary = "AI 润色文章文本") + @PostMapping("/polish") + public ApiResult polishText( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody PolishTextDTO dto) { + return ApiResult.success(textPolishService.polishText(userId, dto)); + } + } diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/DiffSegmentVO.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/DiffSegmentVO.java new file mode 100644 index 0000000..a1bd8a2 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/DiffSegmentVO.java @@ -0,0 +1,15 @@ +package fun.nojava.module.blog.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class DiffSegmentVO { + + private int index; + + private String original; + + private String polished; +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/PolishResultVO.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/PolishResultVO.java new file mode 100644 index 0000000..e7e0d93 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/PolishResultVO.java @@ -0,0 +1,25 @@ +package fun.nojava.module.blog.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class PolishResultVO { + + private String original; + + private String polished; + + private List diffSegments; + + 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/dto/PolishTextDTO.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/PolishTextDTO.java new file mode 100644 index 0000000..8b6d2b2 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/PolishTextDTO.java @@ -0,0 +1,13 @@ +package fun.nojava.module.blog.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class PolishTextDTO { + + @NotBlank(message = "文本不能为空") + @Size(max = 10000, message = "文本长度不能超过10000") + private String text; +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/service/TextPolishService.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/TextPolishService.java new file mode 100644 index 0000000..d7bff64 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/TextPolishService.java @@ -0,0 +1,9 @@ +package fun.nojava.module.blog.service; + +import fun.nojava.module.blog.dto.PolishResultVO; +import fun.nojava.module.blog.dto.PolishTextDTO; + +public interface TextPolishService { + + PolishResultVO polishText(Long userId, PolishTextDTO dto); +} diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/TextPolishServiceImpl.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/TextPolishServiceImpl.java new file mode 100644 index 0000000..25f3e55 --- /dev/null +++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/TextPolishServiceImpl.java @@ -0,0 +1,225 @@ +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.DiffSegmentVO; +import fun.nojava.module.blog.dto.PolishResultVO; +import fun.nojava.module.blog.dto.PolishTextDTO; +import fun.nojava.module.blog.service.TextPolishService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; + +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TextPolishServiceImpl implements TextPolishService { + + private static final int MIN_TEXT_LENGTH = 20; + private static final String RATE_LIMIT_KEY_PREFIX = "text_polish:rate:"; + + private static final String SYSTEM_PROMPT = + "你是一名专业的文字编辑,请润色用户提供的中文文本。" + + "修正语法错误、优化表达方式、规范标点符号,保持原意不变,使文本更流畅专业。" + + "直接输出润色后的文本,不要添加任何解释或标记。"; + + private final AiSummaryServiceImpl aiSummaryService; + private final DashScopeProperties properties; + private final StringRedisTemplate redisTemplate; + + @Override + public PolishResultVO polishText(Long userId, PolishTextDTO dto) { + String text = dto.getText().trim(); + if (text.length() < MIN_TEXT_LENGTH) { + throw new BusinessException(ErrorCode.AI_CONTENT_TOO_SHORT); + } + if (!hasApiKey()) { + throw new BusinessException(ErrorCode.AI_API_KEY_MISSING); + } + checkRateLimit(userId); + + String input = text.length() > properties.getMaxInput() + ? text.substring(0, properties.getMaxInput()) + : text; + + String userPrompt = "请润色下面这段中文文本:\n" + 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 + ); + + Map response; + try { + response = aiSummaryService.callModel(requestBody); + } catch (ResourceAccessException ex) { + if (ex.getCause() instanceof SocketTimeoutException) { + log.error("文本润色 AI 调用超时, userId={}", userId); + throw new BusinessException(ErrorCode.AI_SERVICE_TIMEOUT); + } + log.error("文本润色 AI 网络异常, userId={}, error={}", userId, ex.getMessage()); + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } catch (RestClientResponseException ex) { + log.error("文本润色 AI HTTP 异常, status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString()); + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + log.error("文本润色 AI 调用失败, userId={}, error={}", userId, ex.getMessage(), ex); + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + + long costMillis = System.currentTimeMillis() - startMillis; + String polishedText = extractContent(response); + List segments = alignParagraphs(text, polishedText); + + 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()); + + log.info("文本润色成功 userId={}, model={}, origLen={}, polishedLen={}, cost={}ms", + userId, model, text.length(), polishedText.length(), costMillis); + + return PolishResultVO.builder() + .original(text) + .polished(polishedText) + .diffSegments(segments) + .model(model) + .promptTokens(promptTokens) + .completionTokens(completionTokens) + .costMillis(costMillis) + .build(); + } + + @SuppressWarnings("unchecked") + private String extractContent(Map response) { + if (response == null) { + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + List> choices = (List>) response.get("choices"); + if (choices == null || choices.isEmpty()) { + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + Map message = (Map) choices.get(0).get("message"); + String content = message == null ? null : (String) message.get("content"); + if (content == null || content.isBlank()) { + throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); + } + return content.trim(); + } + + List alignParagraphs(String original, String polished) { + String[] origParas = original.split("\n\n"); + String[] polParas = polished.split("\n\n"); + + int origLen = origParas.length; + int polLen = polParas.length; + + // 段落数一致:一一对应 + if (origLen == polLen) { + List segments = new ArrayList<>(); + for (int i = 0; i < origLen; i++) { + String o = origParas[i].trim(); + String p = polParas[i].trim(); + if (o.isEmpty() && p.isEmpty()) continue; + segments.add(DiffSegmentVO.builder() + .index(i) + .original(o) + .polished(p) + .build()); + } + return segments.isEmpty() + ? List.of(DiffSegmentVO.builder().index(0).original(original).polished(polished).build()) + : segments; + } + + // 段数差异过大(超过 2 倍):降级为全文单段对比 + if (polLen > origLen * 2 || origLen > polLen * 2) { + return List.of(DiffSegmentVO.builder() + .index(0) + .original(original) + .polished(polished) + .build()); + } + + // 段数略有差异:按较少段落数对齐,多余内容合并到最后一段 + int minLen = Math.min(origLen, polLen); + List segments = new ArrayList<>(); + for (int i = 0; i < minLen; i++) { + String o = origParas[i].trim(); + String p = polParas[i].trim(); + if (o.isEmpty() && p.isEmpty()) continue; + segments.add(DiffSegmentVO.builder() + .index(i) + .original(o) + .polished(p) + .build()); + } + + // 多余的原文段落合并 + if (origLen > minLen) { + StringBuilder sb = new StringBuilder(); + for (int i = minLen; i < origLen; i++) { + if (sb.length() > 0) sb.append("\n\n"); + sb.append(origParas[i].trim()); + } + String lastO = sb.toString(); + if (!lastO.isEmpty()) { + int lastIdx = segments.size(); + String lastP = segments.isEmpty() ? polished : segments.get(segments.size() - 1).getPolished(); + segments.add(DiffSegmentVO.builder() + .index(lastIdx) + .original(lastO) + .polished(lastP) + .build()); + } + } + + return segments.isEmpty() + ? List.of(DiffSegmentVO.builder().index(0).original(original).polished(polished).build()) + : segments; + } + + private boolean hasApiKey() { + return properties.getApiKey() != null && !properties.getApiKey().isBlank(); + } + + private void checkRateLimit(Long userId) { + if (userId == null) return; + String key = RATE_LIMIT_KEY_PREFIX + userId; + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + redisTemplate.expire(key, 1, TimeUnit.MINUTES); + } + if (count != null && count > properties.getRateLimitPerMinute()) { + log.warn("文本润色限流命中 userId={}, count={}", userId, count); + throw new BusinessException(ErrorCode.RATE_LIMITED); + } + } + + private Integer toInt(Object value) { + if (value instanceof Number n) return n.intValue(); + return null; + } +} diff --git a/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TextPolishServiceImplTest.java b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TextPolishServiceImplTest.java new file mode 100644 index 0000000..c07613d --- /dev/null +++ b/jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/TextPolishServiceImplTest.java @@ -0,0 +1,505 @@ +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.DiffSegmentVO; +import fun.nojava.module.blog.dto.PolishResultVO; +import fun.nojava.module.blog.dto.PolishTextDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TextPolishServiceImplTest { + + private DashScopeProperties properties; + private StringRedisTemplate redisTemplate; + private ValueOperations valueOps; + private AiSummaryServiceImpl mockAiSummaryService; + private TextPolishServiceImpl 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(6000); + properties.setRateLimitPerMinute(5); + + redisTemplate = mock(StringRedisTemplate.class); + valueOps = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.increment(anyString())).thenReturn(1L); + + mockAiSummaryService = mock(AiSummaryServiceImpl.class); + + service = new TextPolishServiceImpl(mockAiSummaryService, properties, redisTemplate); + } + + // ==================== P0 测试 ==================== + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-01: 文本过短(<20字符)应抛出 AI_CONTENT_TOO_SHORT") + void shouldRejectWhenTextTooShort() { + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("太短了"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.polishText(1L, dto)); + assertEquals(ErrorCode.AI_CONTENT_TOO_SHORT.getCode(), ex.getCode()); + verify(valueOps, never()).increment(anyString()); + } + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-02: 未配置 API Key 应抛出 AI_API_KEY_MISSING") + void shouldRejectWhenApiKeyMissing() { + properties.setApiKey(""); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.polishText(1L, dto)); + assertEquals(ErrorCode.AI_API_KEY_MISSING.getCode(), ex.getCode()); + verify(valueOps, never()).increment(anyString()); + } + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-03: 单用户超过限流应触发 RATE_LIMITED") + void shouldThrowRateLimitedWhenExceedsLimit() { + when(valueOps.increment("text_polish:rate:7")).thenReturn(6L); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.polishText(7L, dto)); + assertEquals(ErrorCode.RATE_LIMITED.getCode(), ex.getCode()); + verify(mockAiSummaryService, never()).callModel(any()); + } + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-04: 正常返回应填充 original、polished、diffSegments、model、tokens、耗时") + void shouldReturnPolishResultOnSuccess() { + String originalText = "这是一篇关于微服务架构的详细技术文章,涵盖服务注册与发现、配置中心等内容。"; + String polishedText = "这是一篇关于微服务架构的详细技术文章,覆盖了服务注册与发现、配置中心等内容。"; + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse(polishedText)); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText(originalText); + + PolishResultVO result = service.polishText(1L, dto); + + assertNotNull(result.getOriginal()); + assertEquals(originalText, result.getOriginal()); + assertEquals(polishedText, result.getPolished()); + assertNotNull(result.getDiffSegments()); + assertFalse(result.getDiffSegments().isEmpty()); + assertEquals(0, result.getDiffSegments().get(0).getIndex()); + assertEquals(originalText, result.getDiffSegments().get(0).getOriginal()); + assertEquals(polishedText, result.getDiffSegments().get(0).getPolished()); + assertEquals("qwen-turbo", result.getModel()); + assertEquals(123, result.getPromptTokens()); + assertEquals(45, result.getCompletionTokens()); + assertNotNull(result.getCostMillis()); + } + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-05: 多段落应正确对齐到 diffSegments") + void shouldAlignMultipleParagraphs() { + String originalText = "第一段内容。\n\n第二段内容。\n\n第三段内容。"; + String polishedText = "第一段润色后。\n\n第二段润色后。\n\n第三段润色后。"; + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse(polishedText)); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText(originalText); + + PolishResultVO result = service.polishText(1L, dto); + + assertEquals(3, result.getDiffSegments().size()); + assertEquals("第一段内容。", result.getDiffSegments().get(0).getOriginal()); + assertEquals("第一段润色后。", result.getDiffSegments().get(0).getPolished()); + assertEquals("第二段内容。", result.getDiffSegments().get(1).getOriginal()); + assertEquals("第二段润色后。", result.getDiffSegments().get(1).getPolished()); + assertEquals("第三段内容。", result.getDiffSegments().get(2).getOriginal()); + assertEquals("第三段润色后。", result.getDiffSegments().get(2).getPolished()); + } + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-06: choices 为空应映射为 AI_SERVICE_UNAVAILABLE") + void shouldFailWhenChoicesEmpty() { + Map response = new java.util.HashMap<>(); + response.put("choices", List.of()); + response.put("model", "qwen-turbo"); + when(mockAiSummaryService.callModel(any())).thenReturn(response); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.polishText(1L, dto)); + assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode()); + } + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-07: 模型调用异常应映射为 AI_SERVICE_UNAVAILABLE") + void shouldMapExceptionToAiServiceUnavailable() { + when(mockAiSummaryService.callModel(any())) + .thenThrow(new RuntimeException("网络异常")); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.polishText(1L, dto)); + assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode()); + } + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-08: 长文本应截断到 maxInput 上限") + void shouldTruncateLongTextToMaxInput() { + properties.setMaxInput(50); + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse("润色后的文本内容。")); + String longText = "X".repeat(500); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText(longText); + + service.polishText(1L, dto); + + verify(mockAiSummaryService).callModel(argThat(requestBody -> { + @SuppressWarnings("unchecked") + List> messages = (List>) requestBody.get("messages"); + String userContent = (String) messages.get(1).get("content"); + long xCount = userContent.chars().filter(c -> c == 'X').count(); + assertEquals(50, xCount, "文本应截断到 maxInput 字符"); + return true; + })); + } + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-09: 首次调用应设置限流 key 的 1 分钟 TTL") + void shouldExpireRateLimitKeyOnFirstCall() { + when(valueOps.increment("text_polish:rate:8")).thenReturn(1L); + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse("润色后的文本内容。")); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + service.polishText(8L, dto); + + verify(redisTemplate, times(1)).expire(eq("text_polish:rate:8"), eq(1L), any()); + } + + @Test + @Tag("p0") + @DisplayName("TC-POLISH-10: userId 为 null 时不应限流,应正常调用模型") + void shouldNotRateLimitWhenUserIdNull() { + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse("润色后的文本内容。")); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + PolishResultVO result = service.polishText(null, dto); + + assertNotNull(result.getPolished()); + verify(mockAiSummaryService).callModel(any()); + } + + // ==================== P1 测试 ==================== + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-11: 模型返回 content 为 blank 应映射为 AI_SERVICE_UNAVAILABLE") + void shouldFailWhenContentIsBlank() { + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse(" ")); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.polishText(1L, dto)); + assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode()); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-12: 模型返回 content 为 null 应映射为 AI_SERVICE_UNAVAILABLE") + void shouldFailWhenContentIsNull() { + Map response = new java.util.HashMap<>(); + response.put("model", "qwen-turbo"); + Map message = new java.util.HashMap<>(); + message.put("role", "assistant"); + message.put("content", null); + response.put("choices", List.of(Map.of("message", message))); + when(mockAiSummaryService.callModel(any())).thenReturn(response); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.polishText(1L, dto)); + assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode()); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-13: 响应中无 usage 字段时 tokens 应为 null") + void shouldReturnNullTokensWhenNoUsage() { + Map response = Map.of( + "model", "qwen-turbo", + "choices", List.of( + Map.of("message", Map.of("role", "assistant", "content", "润色后的文本。")) + ) + ); + when(mockAiSummaryService.callModel(any())).thenReturn(response); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + PolishResultVO result = service.polishText(1L, dto); + + assertNull(result.getPromptTokens()); + assertNull(result.getCompletionTokens()); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-14: 段落数差异超过 2 倍应降级为全文单段对比(原文段落多)") + void shouldFallbackWhenParagraphCountDiffersTooMuchMoreOriginal() { + String originalText = "第一段落内容足够长。\n\n第二段落内容足够长。\n\n第三段落内容足够长。\n\n第四段落内容足够长。\n\n第五段落内容足够长。"; + String polishedText = "全部合并为一段润色后的文本内容。"; + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse(polishedText)); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText(originalText); + + PolishResultVO result = service.polishText(1L, dto); + + assertEquals(1, result.getDiffSegments().size(), "应降级为单段对比"); + assertEquals(originalText, result.getDiffSegments().get(0).getOriginal()); + assertEquals(polishedText, result.getDiffSegments().get(0).getPolished()); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-15: 段落数差异超过 2 倍应降级为全文单段对比(润色段落多)") + void shouldFallbackWhenParagraphCountDiffersTooMuchMorePolished() { + String originalText = "只有一段但足够长的文本内容用于润色处理。"; + String polishedText = "润色后第一段文本内容足够长。\n\n润色后第二段文本内容足够长。\n\n润色后第三段文本内容足够长。\n\n润色后第四段文本内容足够长。"; + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse(polishedText)); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText(originalText); + + PolishResultVO result = service.polishText(1L, dto); + + assertEquals(1, result.getDiffSegments().size(), "应降级为单段对比"); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-16: 段落数略有差异(不超过2倍)时按较少段落数对齐") + void shouldAlignToMinParagraphsWhenSlightDifference() { + String originalText = "第一篇原文内容足够长。\n\n第二篇原文内容足够长。"; + String polishedText = "第一篇润色后内容足够长。\n\n第二篇润色后内容足够长。\n\n多余的第三段落内容。"; + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse(polishedText)); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText(originalText); + + PolishResultVO result = service.polishText(1L, dto); + + assertEquals(2, result.getDiffSegments().size(), "应按较少段落数(2)对齐"); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-17: 单段文本正常返回") + void shouldWorkWithSingleParagraph() { + String originalText = "这是一段需要润色的单段文本内容,存在一些语法和表达的问题。"; + String polishedText = "这是一段已润色的单段文本内容,修正了语法和表达的问题。"; + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse(polishedText)); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText(originalText); + + PolishResultVO result = service.polishText(1L, dto); + + assertEquals(1, result.getDiffSegments().size()); + assertEquals(originalText, result.getDiffSegments().get(0).getOriginal()); + assertEquals(polishedText, result.getDiffSegments().get(0).getPolished()); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-18: 包含空段落的文本应过滤空段") + void shouldFilterEmptyParagraphs() { + String originalText = "第一段落内容足够长。\n\n\n\n第二段落内容足够长。"; + String polishedText = "第一段润色后内容。\n\n第二段润色后内容。"; + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse(polishedText)); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText(originalText); + + PolishResultVO result = service.polishText(1L, dto); + + assertFalse(result.getDiffSegments().isEmpty()); + // 过滤后应有2段有效内容 + assertTrue(result.getDiffSegments().size() >= 1); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-19: 文本前后空格应被 trim") + void shouldTrimTextBeforeProcessing() { + String originalText = " 这是一段前后有空格的文本,足够长以通过长度校验测试。 "; + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse("润色后的内容。")); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText(originalText); + + PolishResultVO result = service.polishText(1L, dto); + + assertFalse(result.getOriginal().startsWith(" "), "开头空格应被 trim"); + assertFalse(result.getOriginal().endsWith(" "), "结尾空格应被 trim"); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-20: response 为 null 应映射为 AI_SERVICE_UNAVAILABLE") + void shouldFailWhenResponseIsNull() { + when(mockAiSummaryService.callModel(any())).thenReturn(null); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.polishText(1L, dto)); + assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode()); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-21: choices 中 message 为 null 应映射为 AI_SERVICE_UNAVAILABLE") + void shouldFailWhenMessageIsNull() { + Map response = new java.util.HashMap<>(); + response.put("model", "qwen-turbo"); + Map choice = new java.util.HashMap<>(); + choice.put("message", null); + response.put("choices", List.of(choice)); + when(mockAiSummaryService.callModel(any())).thenReturn(response); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.polishText(1L, dto)); + assertEquals(ErrorCode.AI_SERVICE_UNAVAILABLE.getCode(), ex.getCode()); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-22: 传递的 model、temperature、messages 结构应正确") + void shouldBuildCorrectModelRequestBody() { + when(mockAiSummaryService.callModel(any())) + .thenReturn(buildModelResponse("润色后的文本。")); + + PolishTextDTO dto = new PolishTextDTO(); + dto.setText("这是一段足够长的文本内容,用于触发文本润色测试用例验证。"); + + service.polishText(1L, dto); + + verify(mockAiSummaryService).callModel(argThat(requestBody -> { + assertEquals("qwen-turbo", requestBody.get("model")); + assertEquals(0.3, requestBody.get("temperature")); + @SuppressWarnings("unchecked") + List> messages = (List>) requestBody.get("messages"); + assertEquals(2, messages.size()); + assertEquals("system", messages.get(0).get("role")); + assertEquals("user", messages.get(1).get("role")); + String userContent = (String) messages.get(1).get("content"); + assertTrue(userContent.contains("请润色下面这段中文文本"), "User prompt 应包含润色指令"); + assertTrue(userContent.contains("测试用例验证"), "User prompt 应包含正文内容"); + return true; + })); + } + + // ==================== 段落对齐专项测试 ==================== + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-ALIGN-01: 原文与润色段落数相等——一一对应") + void alignParagraphs_equalCount() { + List result = service.alignParagraphs("A\n\nB\n\nC", "A'\n\nB'\n\nC'"); + assertEquals(3, result.size()); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-ALIGN-02: 润色结果完全一致时仍返回对比") + void alignParagraphs_identicalText() { + List result = service.alignParagraphs("相同内容", "相同内容"); + assertEquals(1, result.size()); + assertEquals("相同内容", result.get(0).getOriginal()); + assertEquals("相同内容", result.get(0).getPolished()); + } + + @Test + @Tag("p1") + @DisplayName("TC-POLISH-ALIGN-03: 原文和润色结果均为空时应降级返回") + void alignParagraphs_bothEmpty() { + List result = service.alignParagraphs("", ""); + assertEquals(1, result.size()); + } + + // ==================== 辅助方法 ==================== + + private Map buildModelResponse(String polishedText) { + return Map.of( + "model", "qwen-turbo", + "choices", List.of( + Map.of("message", Map.of("role", "assistant", "content", polishedText)) + ), + "usage", Map.of( + "prompt_tokens", 123, + "completion_tokens", 45, + "total_tokens", 168 + ) + ); + } +}