ai文章润色
This commit is contained in:
@@ -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:你是一名专业的文字编辑,请润色用户提供的中文文本。修正语法错误、优化表达方式、规范标点符号,保持原意不变,使文本更流畅专业。直接输出润色后的文本,不要添加任何解释或标记。<br>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
|
||||
@@ -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<DiffSegmentVO> 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<String, Object> 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<String, Object> response = aiSummaryService.callModel(requestBody);
|
||||
|
||||
// 3. 段落对齐:按原文换行符拆分,将润色结果对齐到对应段落
|
||||
List<DiffSegmentVO> 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
|
||||
<!-- ArticleEditor.vue 工具栏区域 -->
|
||||
<div v-if="editor" class="toolbar">
|
||||
<el-button-group>
|
||||
<!-- 现有按钮:B I H2 H3 列表 引用 分割线 -->
|
||||
</el-button-group>
|
||||
<div class="toolbar-right">
|
||||
<el-button
|
||||
type="warning"
|
||||
size="small"
|
||||
:loading="polishing"
|
||||
@click="handlePolishText"
|
||||
>
|
||||
✨ AI 润色
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 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
|
||||
<!-- PolishDiffPanel.vue -->
|
||||
<template>
|
||||
<div class="polish-diff-panel">
|
||||
<div class="panel-header">
|
||||
<span>AI 润色结果对比</span>
|
||||
<div>
|
||||
<el-button type="success" size="small" @click="acceptAll">全部采纳</el-button>
|
||||
<el-button size="small" @click="handleClose">✕</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="diff-content">
|
||||
<div
|
||||
v-for="seg in segments"
|
||||
:key="seg.index"
|
||||
class="diff-row"
|
||||
:class="{ accepted: seg.accepted }"
|
||||
>
|
||||
<div class="diff-original">{{ seg.original }}</div>
|
||||
<div class="diff-polished">{{ seg.polished }}</div>
|
||||
<el-button
|
||||
v-if="!seg.accepted"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="acceptSegment(seg)"
|
||||
>
|
||||
采纳
|
||||
</el-button>
|
||||
<span v-else class="accepted-tag">已采纳</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
result: Object, // PolishResultVO
|
||||
visible: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['accept-segment', 'accept-all', 'close'])
|
||||
|
||||
const segments = ref([])
|
||||
|
||||
watch(() => props.result, (val) => {
|
||||
if (val) {
|
||||
segments.value = val.diffSegments.map(s => ({ ...s, accepted: false }))
|
||||
}
|
||||
})
|
||||
|
||||
const acceptSegment = (seg) => {
|
||||
seg.accepted = true
|
||||
emit('accept-segment', seg.index, seg.polished)
|
||||
}
|
||||
|
||||
const acceptAll = () => {
|
||||
segments.value.forEach(s => { s.accepted = true })
|
||||
emit('accept-all')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
const hasUnaccepted = segments.value.some(s => !s.accepted)
|
||||
if (hasUnaccepted) {
|
||||
ElMessageBox.confirm('有未采纳的润色结果,确定关闭?', '提示')
|
||||
.then(() => emit('close'))
|
||||
.catch(() => {})
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
#### 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 字符,前端应在选中文本过长时给出提示
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -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']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div v-if="visible" class="polish-diff-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">AI 润色结果对比</span>
|
||||
<div class="panel-actions">
|
||||
<el-button type="success" size="small" @click="handleAcceptAll">全部采纳</el-button>
|
||||
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
v-for="seg in localSegments"
|
||||
:key="seg.index"
|
||||
class="diff-row"
|
||||
:class="{ 'diff-row-accepted': seg.accepted }"
|
||||
>
|
||||
<div class="diff-col diff-original">
|
||||
<div class="diff-label">原文</div>
|
||||
<div class="diff-text">{{ seg.original }}</div>
|
||||
</div>
|
||||
<div class="diff-col diff-polished">
|
||||
<div class="diff-label">润色后</div>
|
||||
<div class="diff-text">{{ seg.polished }}</div>
|
||||
</div>
|
||||
<div class="diff-action">
|
||||
<el-button
|
||||
v-if="!seg.accepted"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleAccept(seg)"
|
||||
>
|
||||
采纳
|
||||
</el-button>
|
||||
<span v-else class="accepted-tag">已采纳</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
result: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['accept-segment', 'accept-all', 'close'])
|
||||
|
||||
const localSegments = ref([])
|
||||
|
||||
watch(() => props.result, (val) => {
|
||||
if (val && val.diffSegments) {
|
||||
localSegments.value = val.diffSegments.map(s => ({ ...s, accepted: false }))
|
||||
} else {
|
||||
localSegments.value = []
|
||||
}
|
||||
})
|
||||
|
||||
function handleAccept(seg) {
|
||||
seg.accepted = true
|
||||
emit('accept-segment', seg.index, seg.polished)
|
||||
if (localSegments.value.every(s => s.accepted)) {
|
||||
emit('accept-all')
|
||||
}
|
||||
}
|
||||
|
||||
function handleAcceptAll() {
|
||||
localSegments.value.forEach(s => { s.accepted = true })
|
||||
emit('accept-all')
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
const hasUnaccepted = localSegments.value.some(s => !s.accepted)
|
||||
if (hasUnaccepted) {
|
||||
ElMessageBox.confirm('有未采纳的润色结果,确定关闭?', '提示', {
|
||||
confirmButtonText: '确定关闭',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
emit('close')
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.polish-diff-panel {
|
||||
margin-top: var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color-light);
|
||||
background: var(--bg-white);
|
||||
}
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.panel-body {
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.diff-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: flex-start;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color-light);
|
||||
background: var(--bg-white);
|
||||
}
|
||||
.diff-row-accepted {
|
||||
opacity: 0.5;
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.diff-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.diff-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.diff-text {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-normal);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.diff-original .diff-text {
|
||||
color: var(--text-primary);
|
||||
background: #fff2f0;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: 2px;
|
||||
border-left: 3px solid #ff4d4f;
|
||||
}
|
||||
.diff-polished .diff-text {
|
||||
color: var(--text-primary);
|
||||
background: #f6ffed;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: 2px;
|
||||
border-left: 3px solid #52c41a;
|
||||
}
|
||||
.diff-action {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
.accepted-tag {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -50,8 +50,24 @@
|
||||
<el-button size="small" @click="editor.chain().focus().toggleBlockquote().run()" :type="editor.isActive('blockquote') ? 'primary' : ''">引用</el-button>
|
||||
<el-button size="small" @click="editor.chain().focus().setHorizontalRule().run()">分割线</el-button>
|
||||
</el-button-group>
|
||||
<el-button
|
||||
type="warning"
|
||||
size="small"
|
||||
:loading="polishing"
|
||||
@click="handlePolishText"
|
||||
class="polish-btn"
|
||||
>
|
||||
AI 润色
|
||||
</el-button>
|
||||
</div>
|
||||
<EditorContent :editor="editor" class="editor-content" />
|
||||
<PolishDiffPanel
|
||||
:result="polishResult"
|
||||
:visible="showDiffPanel"
|
||||
@accept-segment="handleAcceptSegment"
|
||||
@accept-all="handleAcceptAll"
|
||||
@close="showDiffPanel = false"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
@@ -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) {
|
||||
|
||||
+10
@@ -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<PolishResultVO> polishText(
|
||||
@AuthenticationPrincipal Long userId,
|
||||
@Valid @RequestBody PolishTextDTO dto) {
|
||||
return ApiResult.success(textPolishService.polishText(userId, dto));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<DiffSegmentVO> diffSegments;
|
||||
|
||||
private String model;
|
||||
|
||||
private Integer promptTokens;
|
||||
|
||||
private Integer completionTokens;
|
||||
|
||||
private Long costMillis;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+225
@@ -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<String, Object> requestBody = Map.of(
|
||||
"model", properties.getModel(),
|
||||
"messages", List.of(
|
||||
Map.of("role", "system", "content", SYSTEM_PROMPT),
|
||||
Map.of("role", "user", "content", userPrompt)
|
||||
),
|
||||
"temperature", 0.3
|
||||
);
|
||||
|
||||
Map<String, Object> response;
|
||||
try {
|
||||
response = aiSummaryService.callModel(requestBody);
|
||||
} catch (ResourceAccessException ex) {
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
log.error("文本润色 AI 调用超时, userId={}", userId);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_TIMEOUT);
|
||||
}
|
||||
log.error("文本润色 AI 网络异常, userId={}, error={}", userId, ex.getMessage());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (RestClientResponseException ex) {
|
||||
log.error("文本润色 AI HTTP 异常, status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.error("文本润色 AI 调用失败, userId={}, error={}", userId, ex.getMessage(), ex);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
long costMillis = System.currentTimeMillis() - startMillis;
|
||||
String polishedText = extractContent(response);
|
||||
List<DiffSegmentVO> 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<String, Object> response) {
|
||||
if (response == null) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
|
||||
if (choices == null || choices.isEmpty()) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
|
||||
String content = message == null ? null : (String) message.get("content");
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
List<DiffSegmentVO> 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<DiffSegmentVO> 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<DiffSegmentVO> 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;
|
||||
}
|
||||
}
|
||||
+505
@@ -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<String, String> 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<String, Object> 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<Map<String, Object>> messages = (List<Map<String, Object>>) 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<String, Object> response = new java.util.HashMap<>();
|
||||
response.put("model", "qwen-turbo");
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> response = new java.util.HashMap<>();
|
||||
response.put("model", "qwen-turbo");
|
||||
Map<String, Object> 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<Map<String, Object>> messages = (List<Map<String, Object>>) 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<DiffSegmentVO> 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<DiffSegmentVO> 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<DiffSegmentVO> result = service.alignParagraphs("", "");
|
||||
assertEquals(1, result.size());
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private Map<String, Object> 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user