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
+
+
+```
+
+#### 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
+
+
+
+
+
+
+
{{ seg.original }}
+
{{ seg.polished }}
+
+ 采纳
+
+
已采纳
+
+
+
+
+
+
+```
+
+#### 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 @@
+
+
+
+
+
+
+
原文
+
{{ seg.original }}
+
+
+
润色后
+
{{ seg.polished }}
+
+
+
+ 采纳
+
+ 已采纳
+
+
+
+
+
+
+
+
+
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