diff --git a/doc/WPS365回调地址配置说明.md b/doc/WPS365回调地址配置说明.md deleted file mode 100644 index 430a9ef..0000000 --- a/doc/WPS365回调地址配置说明.md +++ /dev/null @@ -1,119 +0,0 @@ -# WPS365 回调地址配置说明 - -## 错误信息 - -如果遇到以下错误: -```json -{ - "code": 40000001, - "msg": "invalid_request", - "debug": { - "desc": "The 'redirect_uri' parameter does not match any of the OAuth 2.0 Client's pre-registered redirect urls." - } -} -``` - -**原因**:`redirect_uri` 参数与WPS365开放平台配置的回调地址不一致。 - -## 解决方案 - -### 1. 检查当前配置的回调地址 - -查看 `application.yml` 中的配置: -```yaml -wps365: - redirect-uri: https://jarvis.van333.cn/wps365-callback -``` - -### 2. 在WPS365开放平台配置回调地址 - -1. **登录WPS365开放平台** - - 访问:https://open.wps.cn/ - - 进入你的应用管理页面 - -2. **找到"安全设置"或"回调配置"** - - 通常在"开发配置" > "安全设置" 或 "事件与回调" 中 - -3. **配置授权回调地址** - - 回调地址必须与配置文件中的 `redirect-uri` **完全一致** - - 包括: - - ✅ 协议:`https://`(不能是 `http://`) - - ✅ 域名:`jarvis.van333.cn`(不能有 `www` 或其他前缀) - - ✅ 路径:`/wps365-callback`(不能有多余的斜杠) - - ✅ 端口:如果使用默认端口443,不需要写端口号 - -4. **正确的配置示例** - ``` - https://jarvis.van333.cn/wps365-callback - ``` - -5. **错误的配置示例(会导致错误)** - ``` - ❌ http://jarvis.van333.cn/wps365-callback (协议错误) - ❌ https://www.jarvis.van333.cn/wps365-callback (域名不一致) - ❌ https://jarvis.van333.cn/wps365-callback/ (末尾多斜杠) - ❌ https://jarvis.van333.cn:443/wps365-callback (端口号多余) - ❌ https://jarvis.van333.cn/jarvis/wps365/oauth/callback (路径不一致) - ``` - -### 3. 验证回调地址 - -配置完成后,WPS365平台会发送一个challenge验证请求到你的回调地址。确保: -- ✅ 回调接口支持GET和POST请求 -- ✅ 能正确处理challenge参数并返回 -- ✅ 接口可以正常访问(不被防火墙拦截) - -### 4. 常见问题排查 - -#### 问题1:配置了但验证失败 -- 检查回调接口是否支持POST请求 -- 检查是否能正确处理challenge参数 -- 查看后端日志,确认是否收到challenge请求 - -#### 问题2:授权时提示redirect_uri不匹配 -- 检查WPS365平台配置的回调地址 -- 检查配置文件中的redirect-uri -- 确保两者完全一致(包括大小写、斜杠等) - -#### 问题3:回调地址访问失败 -- 检查nginx配置是否正确代理 -- 检查前端白名单是否已配置 -- 检查域名DNS解析是否正常 - -### 5. 调试步骤 - -1. **查看后端日志** - ``` - 生成授权URL: https://openapi.wps.cn/oauth2/auth?client_id=xxx&redirect_uri=... - 使用回调地址: https://jarvis.van333.cn/wps365-callback - ``` - -2. **复制日志中的回调地址** - - 确保WPS365平台配置的地址与日志中的地址完全一致 - -3. **测试回调地址** - - 直接在浏览器访问:`https://jarvis.van333.cn/wps365-callback` - - 应该能看到授权页面或提示信息 - -4. **重新授权** - - 修改配置后,重新点击"立即授权" - - 如果还是失败,检查WPS365平台的配置 - -## 配置检查清单 - -- [ ] WPS365开放平台已配置回调地址 -- [ ] 回调地址与配置文件中的redirect-uri完全一致 -- [ ] 回调接口支持GET和POST请求 -- [ ] 回调接口能正确处理challenge验证 -- [ ] 前端白名单已配置 `/wps365-callback` -- [ ] nginx已配置 `/wps365-callback` 路由 -- [ ] 域名DNS解析正常 -- [ ] SSL证书有效 - -## 注意事项 - -1. **回调地址必须使用HTTPS**(生产环境) -2. **路径不能有末尾斜杠**(除非是根路径) -3. **域名必须完全匹配**(不能有www前缀等) -4. **配置后需要等待几分钟生效**(WPS365平台可能有缓存) - diff --git a/doc/WPS365授权错误排查指南.md b/doc/WPS365授权错误排查指南.md deleted file mode 100644 index 8ff6fb5..0000000 --- a/doc/WPS365授权错误排查指南.md +++ /dev/null @@ -1,259 +0,0 @@ -# WPS365 授权错误排查指南 - -## 常见错误类型 - -### 1. invalid_request (40000001) - redirect_uri不匹配 - -```json -{ - "code": 40000001, - "msg": "invalid_request", - "debug": { - "desc": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. The 'redirect_uri' parameter does not match any of the OAuth 2.0 Client's pre-registered redirect urls." - } -} -``` - -**错误含义**:redirect_uri参数值与WPS365平台配置的回调地址不一致 - -### 2. invalid_scope (40000005) - scope权限无效 ⚠️ - -```json -{ - "code": 40000005, - "msg": "invalid_scope", - "debug": { - "desc": "The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'file.read,ksheet.read,user.info'." - } -} -``` - -**错误含义**:请求的scope权限格式不正确,或者应用未申请这些权限 - -## 错误含义总览 - -授权错误可能由以下原因导致: - -1. **缺少必需参数** - 授权请求中缺少某个必需的参数 -2. **参数值无效** - 某个参数的值格式不正确 -3. **参数重复** - 某个参数在请求中出现了多次 -4. **redirect_uri不匹配** - redirect_uri参数值与WPS365平台配置的回调地址不一致 -5. **scope无效** - scope权限格式不正确或未申请 - -## 排查步骤 - -### 1. 查看后端日志 - -查看后端日志中的授权URL和参数清单,例如: - -``` -生成授权URL: https://openapi.wps.cn/oauth2/auth?client_id=xxx&redirect_uri=... -📋 授权请求参数清单: - - client_id: AK20260114NNQJKV - - redirect_uri: https://jarvis.van333.cn/wps365-callback - - response_type: code - - scope: file.read,ksheet.read,user.info - - state: xxxxx -``` - -### 2. 检查参数名是否正确 - -**问题**:WPS365可能使用 `app_id` 而不是标准的 `client_id` - -**解决方案**: -- 如果使用 `client_id` 报错,尝试改为 `app_id` -- 查看WPS365官方文档确认正确的参数名 - -**当前代码使用**:`client_id`(标准OAuth2参数名) - -### 3. 检查必需参数是否齐全 - -授权请求必须包含以下参数: - -| 参数名 | 是否必需 | 说明 | 当前值 | -|--------|---------|------|--------| -| `client_id` 或 `app_id` | ✅ 必需 | 应用ID | 从配置读取 | -| `redirect_uri` | ✅ 必需 | 回调地址 | 从配置读取 | -| `response_type` | ✅ 必需 | 固定值 `code` | `code` | -| `scope` | ✅ 必需 | 权限范围 | `file.read,ksheet.read,user.info` | -| `state` | ⚠️ 推荐 | 防CSRF攻击 | 自动生成 | - -### 4. 检查redirect_uri是否匹配 - -这是最常见的错误原因。必须确保: - -1. **协议一致**:必须是 `https://`(不能是 `http://`) -2. **域名一致**:必须是 `jarvis.van333.cn`(不能有 `www` 前缀) -3. **路径一致**:必须是 `/wps365-callback`(不能有末尾斜杠) -4. **端口一致**:使用默认443端口,不需要写端口号 - -**在WPS365平台配置的回调地址必须与日志中的redirect_uri完全一致** - -### 5. 检查scope权限是否已申请(重要!) - -**如果遇到 `invalid_scope` 错误,请按以下步骤排查:** - -#### 5.1 检查WPS365平台后台的scope格式 - -1. 登录WPS365开放平台 -2. 进入"开发配置" > "权限管理" -3. 查看已申请的权限列表,**注意权限的格式**: - - 必须以 `kso.` 开头,如:`kso.file.read`、`kso.file.readwrite` - - 不是 `file.read` 或 `ksheet.read`(这些格式不存在) - - 根据官方文档:https://open.wps.cn/documents/app-integration-dev/wps365/server/ - -#### 5.2 检查scope分隔符 - -**根据WPS365官方文档,必须使用英文逗号分隔**(不是空格): - -| 分隔符 | 示例 | 说明 | -|--------|------|------| -| **逗号(正确)** | `kso.file.readwrite,kso.doclib.readwrite` | ✅ WPS365官方要求 | -| **空格(错误)** | `kso.file.readwrite kso.doclib.readwrite` | ❌ 会导致invalid_scope | -| **逗号+空格** | `kso.file.readwrite, kso.doclib.readwrite` | ⚠️ 可能支持,但不推荐 | - -**重要**:WPS365官方文档明确要求使用英文逗号 `,` 分隔,不能使用空格。 - -#### 5.3 在配置文件中指定scope - -在 `application.yml` 中添加 `scope` 配置,**必须使用英文逗号分隔**: - -```yaml -wps365: - # 根据WPS365平台后台"权限管理"中显示的实际权限名称配置 - # 使用英文逗号分隔(WPS365官方要求) - # 权限名称必须以 kso. 开头 - scope: kso.file.readwrite -``` - -多个权限示例: - -```yaml -wps365: - # 多个权限用英文逗号分隔,不能有空格 - # 权限名称必须以 kso. 开头 - scope: kso.file.read,kso.file.readwrite -``` - -#### 5.4 确认权限已申请且名称正确 - -**关键步骤**: - -1. **登录WPS365开放平台** - - 访问:https://open.wps.cn/ - - 进入你的应用管理页面 - -2. **查看已申请的权限** - - 进入"开发配置" > "权限管理" - - 查看已申请权限的**准确名称**(注意大小写、分隔符、命名空间) - -3. **对比权限名称** - - 权限名称必须与后台显示的**完全一致** - - 例如:如果后台显示的是 `kso.doclib.readwrite`,不能写成 `kso.doclib.readWrite` 或 `kso_doclib_readwrite` - -4. **常见权限名称示例**(仅供参考,以平台后台实际显示为准): - - `kso.doclib.readwrite` - 文档库读写权限 - - `kso.doclib.read` - 文档库读取权限 - - `ksheet.read` - 在线表格读取权限(如果支持) - -**重要提示**: -- ⚠️ `file.read`、`ksheet.read`、`user.info` 这些权限名称可能不存在 -- ✅ 必须查看WPS365平台后台实际显示的权限名称 -- ✅ 权限名称必须完全匹配(包括大小写、分隔符、命名空间) -- ✅ 必须使用英文逗号分隔多个权限 - -### 6. 尝试修改参数名 - -如果确认所有参数都正确,但仍然报错,可能是参数名问题: - -**测试方案1**:将 `client_id` 改为 `app_id` - -修改 `WPS365OAuthServiceImpl.java` 中的代码: -```java -// 原代码 -authUrl.append("?client_id=").append(appId); - -// 改为 -authUrl.append("?app_id=").append(appId); -``` - -**测试方案2**:同时提供两个参数(如果WPS365支持) -```java -authUrl.append("?app_id=").append(appId); -authUrl.append("&client_id=").append(appId); -``` - -### 7. 检查URL编码 - -确保 `redirect_uri` 参数正确进行了URL编码: -- 空格应该编码为 `%20` -- 斜杠 `/` 应该编码为 `%2F` -- 冒号 `:` 应该编码为 `%3A` - -查看日志中的"编码后"值,确认编码是否正确。 - -### 8. 验证WPS365平台配置 - -登录WPS365开放平台,检查: - -1. **应用ID(AppId)** 是否与配置文件中的 `app-id` 一致 -2. **回调地址配置** 是否与日志中的 `redirect_uri` 完全一致 -3. **权限配置** 是否已申请所需的scope权限 -4. **应用状态** 是否为"已上线"或"测试中" - -## 常见问题 - -### Q1: 参数名应该用 `client_id` 还是 `app_id`? - -**A**: 根据WPS365官方文档确认。标准OAuth2使用 `client_id`,但某些平台可能使用 `app_id`。如果 `client_id` 不工作,尝试 `app_id`。 - -### Q2: 为什么redirect_uri明明配置了还是报错? - -**A**: 最常见的原因是: -- 平台配置的回调地址与代码中的不完全一致(多了/少了斜杠、协议不同等) -- 平台配置未保存或未生效 -- 使用了错误的配置环境(开发/生产) - -### Q3: scope权限在哪里申请? - -**A**: 在WPS365开放平台的"开发配置" > "权限管理"中申请。 - -### Q4: 如何确认参数是否正确? - -**A**: 查看后端日志,会打印完整的授权URL和参数清单。对比WPS365平台配置,确保完全一致。 - -### Q5: 遇到 `invalid_scope` 错误怎么办? ⚠️ - -**A**: 这是scope格式或权限问题,按以下步骤排查: - -1. **查看WPS365平台后台的权限格式** - - 进入"开发配置" > "权限管理" - - 查看已申请权限的**确切格式**(包括分隔符、大小写) - -2. **尝试不同的scope格式** - - 空格分隔:`file.read ksheet.read user.info`(当前默认) - - 逗号分隔:`file.read,ksheet.read,user.info` - - 在 `application.yml` 中配置 `scope` 参数进行测试 - -3. **确认权限已申请且已审核通过** - - 权限必须处于"已通过"或"可用"状态 - - 如果权限未申请,需要先申请并等待审核 - -4. **查看后端日志中的scope值** - - 日志会显示实际使用的scope格式 - - 对比WPS365平台显示的格式,确保完全一致 - -## 调试建议 - -1. **启用DEBUG日志**:在 `application.yml` 中设置日志级别为DEBUG -2. **查看完整授权URL**:复制日志中的授权URL,在浏览器中访问,查看具体错误 -3. **对比官方文档**:查看WPS365官方OAuth文档,确认参数名和格式 -4. **联系WPS365技术支持**:如果所有参数都正确但仍报错,可能是平台问题 - -## 下一步 - -如果按照以上步骤排查后仍然报错,请提供: -1. 后端日志中的完整授权URL和参数清单 -2. WPS365平台配置的回调地址截图 -3. WPS365平台的应用配置截图(隐藏敏感信息) - diff --git a/doc/WPS365智能表格AirSheet接口说明.md b/doc/WPS365智能表格AirSheet接口说明.md deleted file mode 100644 index 69795f2..0000000 --- a/doc/WPS365智能表格AirSheet接口说明.md +++ /dev/null @@ -1,39 +0,0 @@ -# WPS365 智能表格(AirSheet)接口说明 - -## 当前情况 - -- **智能表格(AirSheet)** 在 open.wps.cn 开放平台申请的权限为 `kso.airsheet.readwrite`,但 **openapi.wps.cn 下目前没有可用的 AirSheet REST 接口**: - - `https://openapi.wps.cn/api/v1/openapi/airsheet/{fileId}/...` → 404 - - `https://openapi.wps.cn/v7/airsheet/{fileId}/...` → 404 - - 因此直接按「智能表格」调 AirSheet 读/写会报 **读取AirSheet数据失败、statusCode=404**。 - -## 代码中的处理 - -- **读**:`readAirSheetCells` 会依次尝试 - 1)AirSheet 路径 → 2)**KSheet(在线表格)路径**(同一 `fileId` 当作 `file_token`)→ 3)v7 AirSheet。 - 若都 404,会抛出带说明的异常。 -- **写**:`updateAirSheetCells` 同样会先试 AirSheet,再回退到 **KSheet 写入**,再试 v7。 - -即:当 AirSheet 接口 404 时,会自动用同一 ID 试 **在线表格(KSheet)** 接口;若文档在 WPS 文件列表里是以 KSheet 形式存在的,有可能用 KSheet 读/写成功。 - -## 建议用法(避免 404) - -1. **优先用「文件列表」返回的 file_token 调 KSheet 接口** - - 调用 `GET /jarvis/wps365/files`(或等价的文件列表接口),拿到目标文档的 **file_token**。 - - 用该 **file_token** 调用 **KSheet** 读/写接口,不要用浏览器地址栏或分享链接里的 ID 当作 file_token: - - 读:`GET /jarvis/wps365/readCells?userId=xxx&fileToken=文件列表返回的file_token&sheetIdx=0&range=A1:B5` - - 写:`POST /jarvis/wps365/updateCells`,body 里 `fileToken` 填文件列表返回的 file_token。 - - 这样即使用户创建的是「智能表格」,只要在文件列表里存在且后端用 KSheet 能访问,就可正常读/写。 - -2. **确认文档来源** - - 若文档是在 **金山文档(kdocs.cn)** 创建的,当前项目用的是 **open.wps.cn(WPS365)** 的 OAuth 与 API,token 不能直接访问金山文档。 - - 要在后端读/写金山文档里的表格,需要接 **金山文档开放平台(developer.kdocs.cn)** 的 KSheet 服务端 API,并使用该平台的鉴权方式。 - -3. **若必须用「智能表格」且仅支持 AirScript** - - 若官方仅提供通过 **AirScript(脚本)** 操作智能表格(例如 `POST https://www.kdocs.cn/api/v3/ide/file/:file_id/script/:script_id/sync_task`),则需在表格内编写脚本,后端只调该脚本接口,而不是直接调 REST 读单元格。 - -## 小结 - -- **404 原因**:openapi.wps.cn 下当前没有可用的智能表格(AirSheet)REST 读/写接口。 -- **可行方案**:用 **文件列表接口拿 file_token**,用 **KSheet 读/写接口**(readCells / updateCells)操作该文档;若文档在金山文档,需改用金山文档开放平台的 API。 diff --git a/doc/WPS365获取文件列表-测试说明.md b/doc/WPS365获取文件列表-测试说明.md deleted file mode 100644 index fdc5444..0000000 --- a/doc/WPS365获取文件列表-测试说明.md +++ /dev/null @@ -1,70 +0,0 @@ -# WPS365 获取文件列表 - 测试说明 - -## 关于接口与文档 - -- **用户信息接口**:官方文档(open.wps.cn 用户授权流程)只写“通过 access_token 获取用户信息”,**未给出具体 URL**。项目中曾用 `/api/v1/user/info`,该地址会 404。已改为依次尝试 `v7/user`、`v7/userinfo`、`userinfo`、`user/info`;若均不可用则**降级返回**(不报 500,返回 token 中的 user_id 与占位信息)。若需完整用户信息,请以 [WPS 开放平台文档](https://open.wps.cn/documents) 为准确认正确路径后再改。 -- 其他 WPS 接口(文件列表、KSheet 等)请以 open.wps.cn 对应文档为准,发现路径不一致时以文档为准修改。 - -## 接口说明 - -- **后端接口**:`GET /jarvis/wps365/files` -- **参数**: - - `userId`(必填):与授权时保存 Token 使用的用户标识,前端 WPS365 页目前固定为 `default_user` - - `page`(可选,默认 1):页码 - - `pageSize`(可选,默认 20):每页条数 -- **鉴权**:需登录系统(请求头带 JWT),后端用 `userId` 从 Redis 取 WPS365 的 access_token,再请求 WPS 云文档文件列表 API。 - -## 前置条件 - -1. **已完成 WPS365 授权** - 在系统里至少完成一次 WPS365 授权,且回调成功后 Token 已保存(与 `userId` 对应,一般为 `default_user`)。 -2. **已登录系统** - 使用已登录账号访问前端或调用接口(需携带有效 JWT)。 - -## 测试方式一:前端页面(推荐) - -1. 登录若依前端。 -2. 打开 **WPS365 在线表格管理** 页面:侧边栏 **文档同步配置** → **WPS365 在线表格管理**(或直接访问 `/docSync/wps365`)。 -3. 若未授权:点击「立即授权」,在新窗口完成 WPS365 授权后关闭,回到本页。 -4. 确认顶部为「已授权」绿色提示。 -5. 在「文件列表」卡片中点击 **「加载文件」**。 -6. 观察: - - 表格中是否出现文件行(文件名、文件 Token、类型等); - - 浏览器开发者工具 → 网络:找到 `files` 请求,查看请求 URL 是否为 `/jarvis/wps365/files?userId=default_user&page=1&pageSize=20`,以及响应 body 中是否有文件数据。 - -**预期**:能拉取到 WPS 云文档中的文件列表;列表中每条会包含 `file_token`,后续读/写单元格需使用该 `file_token`。 - -若提示「用户未授权」:说明当前 `userId`(如 `default_user`)下没有 WPS365 Token,需重新走一遍授权并确保回调成功。 -若提示「获取文件列表失败」且带 HTTP 状态码或 WPS 错误信息:需看后端日志中请求的 WPS API 地址与返回内容,再对照 [WPS 开放平台-云文档](https://open.wps.cn/documents/app-integration-dev/wps365/server/yundoc/introduce.html) 文档排查。 - -## 测试方式二:curl(需 JWT) - -1. 从浏览器登录后,在开发者工具中复制当前请求的 `Authorization` 头(或从 localStorage 等获取 token 拼成 `Bearer `)。 -2. 执行(将 `YOUR_JWT` 和 `BASE_URL` 替换为实际值): - -```bash -curl -s -X GET "http://localhost:30313/jarvis/wps365/files?userId=default_user&page=1&pageSize=20" \ - -H "Authorization: Bearer YOUR_JWT" -``` - -3. 查看响应: - - `code: 200` 且 `data` 中有文件列表(如 `data.files`、`data.total` 等)则说明获取文件列表能力正常; - - `msg` 为「用户未授权」则需先完成 WPS365 授权; - - 其他错误可根据 `msg` 或后端日志排查。 - -## 后端实际请求的 WPS API(已按官方文档调整) - -- **官方结构**:云文档文件在「驱动盘」下,路径为 `GET https://openapi.wps.cn/v7/drives/{drive_id}/files`(参见 [云文档业务域概述](https://open.wps.cn/documents/app-integration-dev/wps365/server/yundoc/introduce.html))。 -- **当前实现**: - 1. 先请求 `GET https://openapi.wps.cn/v7/drives` 获取驱动盘列表,取第一个或 `allotee_type=user` 的 `drive_id`; - 2. 若无则尝试 `GET https://openapi.wps.cn/v7/drives/me`; - 3. 再请求 `GET https://openapi.wps.cn/v7/drives/{drive_id}/files?page=1&page_size=20`。 -- **鉴权**:Header 中带 WPS365 的 `access_token`(Bearer)。若接口要求 KSO-1 签名,需按官方文档在请求头中增加签名后再试。 - -## 成功后的下一步 - -- 从文件列表结果中取目标文件的 **`file_token`**。 -- 读单元格:使用 **KSheet 读接口** `readCells`,参数中 `fileToken` 填该 `file_token`。 -- 写单元格:使用 **KSheet 写接口** `updateCells`,同样使用该 `file_token`。 - -这样可避免用分享链接里的 ID 直接调接口导致的 404,改为用「文件列表」返回的 `file_token` 访问表格。 diff --git a/doc/WPS365读取在线表格配置指南.md b/doc/WPS365读取在线表格配置指南.md deleted file mode 100644 index 48a49d7..0000000 --- a/doc/WPS365读取在线表格配置指南.md +++ /dev/null @@ -1,271 +0,0 @@ -# WPS365 读取在线表格内容配置指南 - -## 概述 - -本指南详细说明如何配置WPS365应用,以实现读取自己创建的在线表格内容。 - -## 一、WPS365开放平台配置步骤 - -### 1. 应用基础配置 - -1. **登录WPS365开放平台** - - 访问:https://open.wps.cn/ - - 使用WPS账号登录 - -2. **创建应用** - - 进入"应用管理" - - 点击"创建应用" - - 填写应用名称(如:Jarvis同步WPS) - - 选择应用类型:**服务端应用** - -3. **获取应用凭证** - - 记录 `AppID`(应用ID) - - 记录 `AppKey`(应用密钥,注意保密) - -### 2. 配置回调地址 - -1. **进入"开发配置" > "事件与回调"** -2. **配置回调地址** - - 回调地址:`https://jarvis.van333.cn/wps365-callback` - - 点击"验证"按钮,确保验证通过 - - 如果验证失败,检查: - - 回调接口是否支持GET和POST请求 - - 是否能正确处理challenge参数 - - 前端白名单是否已配置 - -### 3. 配置权限(关键步骤) - -#### 3.1 进入权限管理 - -1. 在左侧导航栏选择 **"开发配置" > "权限管理"** -2. 查看当前应用的权限列表 - -#### 3.2 添加必要权限 - -需要添加以下权限才能读取在线表格内容: - -**基础权限:** -- ✅ `file.read` - 读取文件权限 -- ✅ `file.readwrite` - 读取和写入文件权限(如果只需要读取,可以只选read) -- ✅ `user.info` - 获取用户信息权限 - -**在线表格(KSheet)权限:** -- ✅ `ksheet:read` - 读取在线表格数据 -- ✅ `ksheet:write` - 写入在线表格数据(如果需要编辑功能) - -**权限说明:** -- 这些权限决定了你的应用能访问哪些资源 -- 用户授权时,会看到这些权限的说明 -- 只有用户同意授权后,应用才能访问对应的资源 - -#### 3.3 权限申请流程 - -1. 在权限管理页面,点击"添加权限" -2. 搜索并选择上述权限 -3. 提交权限申请(部分权限可能需要审核) -4. 等待审核通过 - -### 4. 应用能力配置(可选) - -根据图片显示,WPS365开放平台提供了多种应用能力: - -**协作与会话:** -- WPS协作机器人 -- 工作台小组件 -- WPS协作网页应用 - -**多维表格:** -- 记录卡片插件 -- 字段插件 -- 视图插件 -- 仪表盘插件 -- 数据连接器插件 -- 自动化插件 - -**注意:** -- 如果只是读取在线表格内容,通常不需要开启这些插件能力 -- 这些能力主要用于扩展表格功能,不是读取数据的必要条件 -- 读取数据主要依赖API权限,而不是这些插件能力 - -## 二、后端配置 - -### 1. 更新配置文件 - -在 `application-dev.yml` 中添加配置: - -```yaml -wps365: - # 应用ID(从WPS365开放平台获取) - app-id: YOUR_APP_ID - # 应用密钥(从WPS365开放平台获取) - app-key: YOUR_APP_KEY - # 授权回调地址 - redirect-uri: https://jarvis.van333.cn/wps365-callback - # API基础地址 - api-base-url: https://open.wps.cn/api/v1 - # OAuth授权地址 - oauth-url: https://open.wps.cn/oauth2/v1/authorize - # 获取Token地址 - token-url: https://open.wps.cn/oauth2/v1/token - # 刷新Token地址 - refresh-token-url: https://open.wps.cn/oauth2/v1/token -``` - -### 2. 确认授权范围(Scope) - -当前代码中使用的授权范围: -```java -scope=file.readwrite,user.info -``` - -如果需要更细粒度的权限控制,可以修改为: -```java -scope=file.read,ksheet:read,user.info // 只读权限 -// 或 -scope=file.readwrite,ksheet:readwrite,user.info // 读写权限 -``` - -## 三、使用流程 - -### 1. 用户授权 - -1. 调用 `/jarvis/wps365/authUrl` 获取授权URL -2. 用户访问授权URL,同意授权 -3. WPS365回调到 `/wps365-callback`,自动保存Token - -### 2. 获取文件列表 - -```javascript -// 调用接口获取文件列表 -GET /jarvis/wps365/files?userId=xxx&page=1&pageSize=20 -``` - -### 3. 获取工作表列表 - -```javascript -// 获取指定文件的工作表列表 -GET /jarvis/wps365/sheets?userId=xxx&fileToken=xxx -``` - -### 4. 读取单元格数据 - -```javascript -// 读取指定范围的单元格数据 -GET /jarvis/wps365/readCells?userId=xxx&fileToken=xxx&sheetIdx=0&range=A1:Z100 -``` - -**参数说明:** -- `userId`: 用户ID(授权后获取) -- `fileToken`: 文件Token(从文件列表中获取) -- `sheetIdx`: 工作表索引(从0开始) -- `range`: 单元格范围(如:A1:Z100,可选,不填则读取整个工作表) - -## 四、常见问题 - -### Q1: 提示"权限不足"或"无权限访问" - -**解决方案:** -1. 检查WPS365开放平台的权限配置 -2. 确认已添加 `file.read` 和 `ksheet:read` 权限 -3. 重新授权(删除旧Token,重新获取授权) - -### Q2: 无法读取自己创建的表格 - -**可能原因:** -1. 文件Token不正确 -2. 工作表索引错误 -3. 用户对文件没有读取权限(即使是自己创建的,也需要授权给应用) - -**解决方案:** -1. 确认文件Token是从文件列表中正确获取的 -2. 使用 `/jarvis/wps365/sheets` 接口确认工作表索引 -3. 确保用户已授权应用访问该文件 - -### Q3: 读取返回空数据 - -**可能原因:** -1. 指定的单元格范围没有数据 -2. 工作表索引错误 -3. API返回格式与预期不符 - -**解决方案:** -1. 尝试读取更大的范围(如:A1:Z1000) -2. 检查工作表索引是否正确 -3. 查看后端日志,确认API返回的具体内容 - -## 五、API接口说明 - -### 读取单元格数据接口 - -**接口地址:** `GET /jarvis/wps365/readCells` - -**请求参数:** -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| userId | String | 是 | 用户ID | -| fileToken | String | 是 | 文件Token | -| sheetIdx | Integer | 否 | 工作表索引,默认0 | -| range | String | 否 | 单元格范围,如:A1:B10 | - -**返回示例:** -```json -{ - "code": 200, - "msg": "读取单元格数据成功", - "data": { - "values": [ - ["列1", "列2", "列3"], - ["数据1", "数据2", "数据3"], - ["数据4", "数据5", "数据6"] - ] - } -} -``` - -## 六、完整示例 - -### 前端调用示例 - -```javascript -import { - getWPS365AuthUrl, - getWPS365FileList, - getWPS365SheetList, - readWPS365Cells -} from '@/api/jarvis/wps365' - -// 1. 获取授权URL -const authResponse = await getWPS365AuthUrl() -window.open(authResponse.data, '_blank') - -// 2. 等待授权完成后,获取文件列表 -const fileListResponse = await getWPS365FileList({ - userId: 'your_user_id', - page: 1, - pageSize: 20 -}) - -// 3. 选择要读取的文件,获取工作表列表 -const fileToken = fileListResponse.data.files[0].file_token -const sheetListResponse = await getWPS365SheetList('your_user_id', fileToken) - -// 4. 读取第一个工作表的数据 -const dataResponse = await readWPS365Cells({ - userId: 'your_user_id', - fileToken: fileToken, - sheetIdx: 0, - range: 'A1:Z100' -}) - -console.log('表格数据:', dataResponse.data.values) -``` - -## 七、注意事项 - -1. **权限范围**:确保在WPS365开放平台配置了正确的权限 -2. **文件Token**:使用fileToken而不是fileId来访问文件 -3. **工作表索引**:工作表索引从0开始,不是从1开始 -4. **单元格范围**:范围格式为 `A1:B10`,不区分大小写 -5. **Token有效期**:Token会过期,需要定期刷新或重新授权 -6. **数据量限制**:一次读取的数据量不要太大,建议分批读取 - diff --git a/doc/WPS365集成使用说明.md b/doc/WPS365集成使用说明.md deleted file mode 100644 index b78f3b8..0000000 --- a/doc/WPS365集成使用说明.md +++ /dev/null @@ -1,270 +0,0 @@ -# WPS365 在线表格API集成使用说明 - -## 概述 - -本功能实现了WPS365在线表格(KSheet)的完整API集成,支持用户授权、文档查看和编辑等功能。 - -## 功能特性 - -1. **OAuth用户授权**:支持WPS365标准的OAuth 2.0授权流程 -2. **Token管理**:自动保存和管理访问令牌,支持Token刷新 -3. **文件管理**:获取文件列表、文件信息 -4. **工作表管理**:获取工作表列表、创建数据表 -5. **单元格编辑**:支持读取和更新单元格数据,支持批量更新 - -## 配置说明 - -### 1. 在WPS365开放平台注册应用 - -1. 访问 [WPS365开放平台](https://open.wps.cn/) -2. 注册成为服务商并创建应用 -3. 获取 `app_id` 和 `app_key` -4. 配置授权回调地址(需要与配置文件中的 `redirect-uri` 一致) - -### 1.1 配置应用权限(重要!) - -在WPS365开放平台的应用配置中,需要确保开启以下权限: - -#### 基础权限 -- **文件读取权限** (`file.read` 或 `file.readwrite`) -- **用户信息权限** (`user.info`) - -#### 在线表格(KSheet)相关权限 -根据图片中的应用能力配置页面,如果需要读取在线表格内容,建议: - -1. **进入"应用能力"配置页面** - - 在左侧导航栏选择"应用能力" > "应用能力" - -2. **确保相关能力已开启** - - 虽然图片中显示的是"多维表格"插件相关能力,但读取在线表格内容主要依赖: - - **文件读取权限**(在"权限管理"中配置) - - **API调用权限**(确保应用有调用OpenAPI的权限) - -3. **权限管理配置** - - 进入"开发配置" > "权限管理" - - 确保添加了以下权限: - - `ksheet:read` - 读取在线表格数据 - - `ksheet:write` - 写入在线表格数据(如果需要编辑) - - `file:read` - 读取文件 - - `file:write` - 写入文件(如果需要编辑) - -4. **事件与回调配置** - - 进入"开发配置" > "事件与回调" - - 配置回调地址:`https://your-domain.com/wps365-callback` - - 确保回调地址验证通过(支持GET和POST请求) - -### 2. 更新配置文件 - -编辑 `application-dev.yml`(或对应的环境配置文件),添加WPS365配置: - -```yaml -wps365: - # 应用ID(从WPS365开放平台获取) - app-id: YOUR_APP_ID - # 应用密钥(从WPS365开放平台获取) - app-key: YOUR_APP_KEY - # 授权回调地址(需要在WPS365开放平台配置) - # 注意:使用 /wps365-callback 路径,避免前端路由拦截 - redirect-uri: https://your-domain.com/wps365-callback - # API基础地址(一般不需要修改) - api-base-url: https://openapi.wps.cn/api/v1 - # OAuth授权地址(一般不需要修改) - oauth-url: https://openapi.wps.cn/oauth2/auth - # 获取Token地址(一般不需要修改) - token-url: https://openapi.wps.cn/oauth2/token - # 刷新Token地址(一般不需要修改) - refresh-token-url: https://openapi.wps.cn/oauth2/token -``` - -**重要提示**: -- 回调地址使用 `/wps365-callback` 而不是 `/jarvis/wps365/oauth/callback` -- 这样可以避免前端路由拦截,确保OAuth回调能正常访问 -- 在WPS365开放平台配置时,只需配置域名(如:`jarvis.van333.cn`),回调路径会自动使用配置中的完整URL - -## API接口说明 - -### 1. 获取授权URL - -**接口**: `GET /jarvis/wps365/authUrl` - -**参数**: -- `state` (可选): 状态参数,用于防止CSRF攻击 - -**返回**: 授权URL,用户需要访问此URL完成授权 - -**示例**: -```javascript -// 前端调用 -import { getWPS365AuthUrl } from '@/api/jarvis/wps365' - -const response = await getWPS365AuthUrl() -const authUrl = response.data -// 打开授权页面 -window.open(authUrl, '_blank') -``` - -### 2. OAuth回调处理 - -**接口**: `GET /wps365-callback` - -**参数**: -- `code`: 授权码(由WPS365回调时自动传入) -- `state`: 状态参数(可选) -- `error`: 错误码(授权失败时) -- `error_description`: 错误描述(授权失败时) - -**说明**: -- 此接口处理WPS365的授权回调,自动获取并保存Token -- 返回HTML页面,显示授权结果 -- 使用 `/wps365-callback` 路径避免前端路由拦截 -- 授权成功后会显示成功页面,3秒后自动关闭窗口 - -### 3. 获取Token状态 - -**接口**: `GET /jarvis/wps365/tokenStatus` - -**参数**: -- `userId`: 用户ID - -**返回**: Token状态信息(是否授权、是否有效等) - -### 4. 刷新Token - -**接口**: `POST /jarvis/wps365/refreshToken` - -**请求体**: -```json -{ - "refreshToken": "refresh_token_value" -} -``` - -### 5. 获取用户信息 - -**接口**: `GET /jarvis/wps365/userInfo` - -**参数**: -- `userId`: 用户ID - -### 6. 获取文件列表 - -**接口**: `GET /jarvis/wps365/files` - -**参数**: -- `userId`: 用户ID -- `page`: 页码(默认1) -- `pageSize`: 每页数量(默认20) - -### 7. 获取工作表列表 - -**接口**: `GET /jarvis/wps365/sheets` - -**参数**: -- `userId`: 用户ID -- `fileToken`: 文件token - -### 8. 读取单元格数据 - -**接口**: `GET /jarvis/wps365/readCells` - -**参数**: -- `userId`: 用户ID -- `fileToken`: 文件token -- `sheetIdx`: 工作表索引(从0开始) -- `range`: 单元格范围(如:A1:B10,可选) - -### 9. 更新单元格数据 - -**接口**: `POST /jarvis/wps365/updateCells` - -**请求体**: -```json -{ - "userId": "user_id", - "fileToken": "file_token", - "sheetIdx": 0, - "range": "A1:B2", - "values": [ - ["值1", "值2"], - ["值3", "值4"] - ] -} -``` - -### 10. 批量更新单元格数据 - -**接口**: `POST /jarvis/wps365/batchUpdateCells` - -**请求体**: -```json -{ - "userId": "user_id", - "fileToken": "file_token", - "sheetIdx": 0, - "updates": [ - { - "range": "A1:B2", - "values": [["值1", "值2"], ["值3", "值4"]] - }, - { - "range": "C1:D2", - "values": [["值5", "值6"], ["值7", "值8"]] - } - ] -} -``` - -## 使用流程 - -### 1. 用户授权流程 - -1. 前端调用 `/jarvis/wps365/authUrl` 获取授权URL -2. 在新窗口打开授权URL,用户完成授权 -3. WPS365会回调到配置的 `redirect-uri`,自动处理授权码 -4. 系统自动获取并保存Token到Redis - -### 2. 编辑文档流程 - -1. 调用 `/jarvis/wps365/files` 获取文件列表 -2. 选择要编辑的文件,调用 `/jarvis/wps365/sheets` 获取工作表列表 -3. 调用 `/jarvis/wps365/readCells` 读取现有数据 -4. 修改数据后,调用 `/jarvis/wps365/updateCells` 更新数据 - -### 3. Token刷新 - -- Token过期前,系统会自动尝试刷新 -- 也可以手动调用 `/jarvis/wps365/refreshToken` 刷新Token - -## 注意事项 - -1. **用户权限**:只有文档的所有者或被授予编辑权限的用户才能编辑文档 -2. **Token管理**:Token存储在Redis中,有效期30天。过期后需要重新授权 -3. **API限制**:注意WPS365的API调用频率限制 -4. **文件Token**:WPS365使用 `file_token` 而不是文件ID,需要从文件列表中获取 - -## 前端页面 - -访问 `/jarvis/wps365` 可以打开WPS365管理页面,包含: -- 授权状态显示 -- 用户信息查看 -- 文件列表浏览 -- 在线编辑表格功能 - -## 错误处理 - -- **未授权**:返回错误提示,引导用户完成授权 -- **Token过期**:自动尝试刷新Token,刷新失败则提示重新授权 -- **权限不足**:返回错误码10003,提示用户没有编辑权限 - -## 技术实现 - -- **后端**:Spring Boot + Redis(Token存储) -- **前端**:Vue.js + Element UI -- **HTTP客户端**:HttpURLConnection(不使用代理,直接连接) - -## 参考文档 - -- [WPS365开放平台文档](https://open.wps.cn/) -- [用户授权流程](https://open.wps.cn/documents/app-integration-dev/wps365/server/certification-authorization/user-authorization/flow) -- [KSheet API文档](https://developer.kdocs.cn/server/ksheet/) - diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/KdocsCallbackController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/KdocsCallbackController.java new file mode 100644 index 0000000..12989e0 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/KdocsCallbackController.java @@ -0,0 +1,86 @@ +package com.ruoyi.web.controller.jarvis; + +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.jarvis.domain.dto.KdocsTokenInfo; +import com.ruoyi.jarvis.service.IKdocsOAuthService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 金山文档 OAuth 回调(独立路径,避免前端路由拦截) + * 回调地址示例:https://your-domain/kdocs-callback + */ +@Anonymous +@RestController +@RequestMapping("/kdocs-callback") +public class KdocsCallbackController extends BaseController { + + private static final Logger log = LoggerFactory.getLogger(KdocsCallbackController.class); + + @Autowired + private IKdocsOAuthService kdocsOAuthService; + + @Anonymous + @GetMapping + public ResponseEntity oauthCallbackGet(@RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "error", required = false) String error, + @RequestParam(value = "error_description", required = false) String errorDescription) { + return handleOAuthCallback(code, state, error, errorDescription); + } + + private ResponseEntity handleOAuthCallback(String code, String state, String error, String errorDescription) { + try { + if (error != null) { + String msg = errorDescription != null ? errorDescription : error; + log.error("金山文档授权失败: {}", msg); + return htmlPage(false, "授权失败: " + msg, null); + } + if (StringUtils.isBlank(code)) { + return htmlPage(false, "缺少授权码 code", null); + } + log.info("金山文档授权回调 code 已收到 state={}", state); + KdocsTokenInfo tokenInfo = kdocsOAuthService.getAccessTokenByCode(code); + if (tokenInfo.getUserId() == null) { + return htmlPage(false, "无法解析用户标识", null); + } + kdocsOAuthService.saveToken(tokenInfo.getUserId(), tokenInfo); + kdocsOAuthService.saveToken("default_user", tokenInfo); + return htmlPage(true, "授权成功,可关闭此窗口", tokenInfo); + } catch (Exception e) { + log.error("OAuth 回调处理失败", e); + return htmlPage(false, "授权失败: " + e.getMessage(), null); + } + } + + private ResponseEntity htmlPage(boolean success, String message, KdocsTokenInfo tokenInfo) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.TEXT_HTML); + String esc = message.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n").replace("\r", "\\r"); + String uid = tokenInfo != null && tokenInfo.getUserId() != null ? tokenInfo.getUserId().replace("\\", "\\\\").replace("'", "\\'") : ""; + StringBuilder html = new StringBuilder(); + html.append("") + .append(success ? "授权成功" : "授权失败") + .append(""); + html.append("

").append(success ? "✓ 授权成功" : "✗ 授权失败").append("

"); + html.append("

").append(message).append("

"); + html.append(""); + return new ResponseEntity<>(html.toString(), headers, HttpStatus.OK); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/KdocsCloudController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/KdocsCloudController.java new file mode 100644 index 0000000..3c98077 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/KdocsCloudController.java @@ -0,0 +1,310 @@ +package com.ruoyi.web.controller.jarvis; + +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.jarvis.domain.dto.KdocsTokenInfo; +import com.ruoyi.jarvis.service.IKdocsOAuthService; +import com.ruoyi.jarvis.service.IKdocsOpenApiService; +import com.ruoyi.jarvis.service.impl.KdocsOAuthServiceImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 金山文档个人云(developer.kdocs.cn) + */ +@RestController +@RequestMapping("/jarvis/kdocs") +public class KdocsCloudController extends BaseController { + + private static final Logger log = LoggerFactory.getLogger(KdocsCloudController.class); + + @Autowired + private IKdocsOAuthService kdocsOAuthService; + @Autowired + private IKdocsOpenApiService kdocsOpenApiService; + @Autowired + private KdocsOAuthServiceImpl kdocsOAuthServiceImpl; + + @GetMapping("/authUrl") + public AjaxResult getAuthUrl(@RequestParam(required = false) String state) { + try { + return AjaxResult.success("获取授权URL成功", kdocsOAuthService.getAuthUrl(state)); + } catch (Exception e) { + log.error("获取授权URL失败", e); + return AjaxResult.error("获取授权URL失败: " + e.getMessage()); + } + } + + @Anonymous + @GetMapping("/oauth/callback") + public AjaxResult oauthCallback(@RequestParam String code, @RequestParam(required = false) String state) { + try { + KdocsTokenInfo tokenInfo = kdocsOAuthService.getAccessTokenByCode(code); + if (tokenInfo.getUserId() != null) { + kdocsOAuthService.saveToken(tokenInfo.getUserId(), tokenInfo); + kdocsOAuthService.saveToken("default_user", tokenInfo); + } + return AjaxResult.success("授权成功", tokenInfo); + } catch (Exception e) { + log.error("OAuth回调失败", e); + return AjaxResult.error("授权失败: " + e.getMessage()); + } + } + + @PostMapping("/refreshToken") + public AjaxResult refreshToken(@RequestBody Map params) { + try { + String refreshToken = (String) params.get("refreshToken"); + String userId = (String) params.get("userId"); + if (StringUtils.isBlank(refreshToken)) { + return AjaxResult.error("refreshToken不能为空"); + } + if (StringUtils.isBlank(userId)) { + userId = "default_user"; + } + KdocsTokenInfo tokenInfo = kdocsOAuthService.refreshAccessToken(refreshToken, userId); + kdocsOAuthService.saveToken(userId, tokenInfo); + if (!userId.equals(tokenInfo.getUserId()) && tokenInfo.getUserId() != null) { + kdocsOAuthService.saveToken(tokenInfo.getUserId(), tokenInfo); + } + kdocsOAuthService.saveToken("default_user", tokenInfo); + return AjaxResult.success("刷新令牌成功", tokenInfo); + } catch (Exception e) { + log.error("刷新访问令牌失败", e); + return AjaxResult.error("刷新令牌失败: " + e.getMessage()); + } + } + + @GetMapping("/tokenStatus") + public AjaxResult getTokenStatus(@RequestParam(required = false) String userId) { + try { + KdocsTokenInfo tokenInfo = null; + if (StringUtils.isNotBlank(userId)) { + tokenInfo = kdocsOAuthServiceImpl.getTokenByUserId(userId); + if (tokenInfo == null && "default_user".equals(userId)) { + tokenInfo = kdocsOAuthService.getCurrentToken(); + if (tokenInfo != null) { + kdocsOAuthService.saveToken("default_user", tokenInfo); + } + } + } else { + tokenInfo = kdocsOAuthService.getCurrentToken(); + } + if (tokenInfo == null) { + JSONObject r = new JSONObject(); + r.put("hasToken", false); + r.put("isValid", false); + return AjaxResult.success("未授权", r); + } + boolean valid = kdocsOAuthService.isTokenValid(tokenInfo); + JSONObject r = new JSONObject(); + r.put("hasToken", true); + r.put("isValid", valid); + r.put("userId", tokenInfo.getUserId()); + r.put("expired", tokenInfo.isExpired()); + if (tokenInfo.getExpiresIn() != null) { + r.put("expiresIn", tokenInfo.getExpiresIn()); + } + return AjaxResult.success("获取Token状态成功", r); + } catch (Exception e) { + log.error("获取Token状态失败", e); + return AjaxResult.error("获取Token状态失败: " + e.getMessage()); + } + } + + @PostMapping("/setToken") + public AjaxResult setToken(@RequestBody Map params) { + try { + String accessToken = (String) params.get("accessToken"); + String refreshToken = (String) params.get("refreshToken"); + String userId = (String) params.get("userId"); + Integer expiresIn = params.get("expiresIn") != null + ? Integer.valueOf(params.get("expiresIn").toString()) : 86400; + if (StringUtils.isBlank(accessToken)) { + return AjaxResult.error("accessToken不能为空"); + } + if (StringUtils.isBlank(userId)) { + return AjaxResult.error("userId不能为空"); + } + KdocsTokenInfo info = new KdocsTokenInfo(); + info.setAccessToken(accessToken); + info.setRefreshToken(refreshToken); + info.setExpiresIn(expiresIn); + info.setUserId(userId); + kdocsOAuthService.saveToken(userId, info); + return AjaxResult.success("设置Token成功"); + } catch (Exception e) { + log.error("设置Token失败", e); + return AjaxResult.error("设置Token失败: " + e.getMessage()); + } + } + + private KdocsTokenInfo ensureToken(String userId) { + KdocsTokenInfo tokenInfo = kdocsOAuthServiceImpl.getTokenByUserId(userId); + if (tokenInfo == null) { + throw new IllegalStateException("用户未授权,请先完成授权"); + } + if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { + tokenInfo = kdocsOAuthService.refreshAccessToken(tokenInfo.getRefreshToken(), userId); + kdocsOAuthService.saveToken(userId, tokenInfo); + kdocsOAuthService.saveToken("default_user", tokenInfo); + } + return tokenInfo; + } + + @GetMapping("/userInfo") + public AjaxResult getUserInfo(@RequestParam String userId) { + try { + KdocsTokenInfo t = ensureToken(userId); + JSONObject userInfo = kdocsOpenApiService.getUserInfoFlat(t.getAccessToken()); + return AjaxResult.success("获取用户信息成功", userInfo); + } catch (IllegalStateException e) { + return AjaxResult.error(e.getMessage()); + } catch (Exception e) { + log.error("获取用户信息失败", e); + return AjaxResult.error("获取用户信息失败: " + e.getMessage()); + } + } + + @GetMapping("/files") + public AjaxResult getFileList(@RequestParam String userId, + @RequestParam(required = false, defaultValue = "1") Integer page, + @RequestParam(required = false, defaultValue = "20") Integer pageSize, + @RequestParam(required = false) Integer next_offset, + @RequestParam(required = false) String next_filter) { + try { + KdocsTokenInfo t = ensureToken(userId); + Map p = new java.util.HashMap<>(); + p.put("page", page); + p.put("page_size", pageSize); + p.put("pageSize", pageSize); + if (next_offset != null) { + p.put("next_offset", next_offset); + } + if (next_filter != null) { + p.put("next_filter", next_filter); + } + JSONObject fileList = kdocsOpenApiService.getFileList(t.getAccessToken(), p); + return AjaxResult.success("获取文件列表成功", fileList); + } catch (IllegalStateException e) { + return AjaxResult.error(e.getMessage()); + } catch (Exception e) { + log.error("获取文件列表失败", e); + return AjaxResult.error("获取文件列表失败: " + e.getMessage()); + } + } + + @GetMapping("/fileInfo") + public AjaxResult getFileInfo(@RequestParam String userId, @RequestParam String fileToken) { + try { + KdocsTokenInfo t = ensureToken(userId); + return AjaxResult.success("获取文件信息成功", kdocsOpenApiService.getFileInfo(t.getAccessToken(), fileToken)); + } catch (IllegalStateException e) { + return AjaxResult.error(e.getMessage()); + } catch (Exception e) { + log.error("获取文件信息失败", e); + return AjaxResult.error("获取文件信息失败: " + e.getMessage()); + } + } + + @GetMapping("/sheets") + public AjaxResult getSheetList(@RequestParam String userId, @RequestParam String fileToken) { + try { + KdocsTokenInfo t = ensureToken(userId); + return AjaxResult.success("获取工作表列表成功", kdocsOpenApiService.getSheetList(t.getAccessToken(), fileToken)); + } catch (IllegalStateException e) { + return AjaxResult.error(e.getMessage()); + } catch (Exception e) { + log.error("获取工作表列表失败", e); + return AjaxResult.error("获取工作表列表失败: " + e.getMessage()); + } + } + + @GetMapping("/readCells") + public AjaxResult readCells(@RequestParam String userId, + @RequestParam String fileToken, + @RequestParam(defaultValue = "0") int sheetIdx, + @RequestParam(required = false) String range) { + try { + KdocsTokenInfo t = ensureToken(userId); + JSONObject data = kdocsOpenApiService.readCells(t.getAccessToken(), fileToken, sheetIdx, range); + return AjaxResult.success("读取单元格数据成功", data); + } catch (IllegalStateException e) { + return AjaxResult.error(e.getMessage()); + } catch (Exception e) { + log.error("读取单元格失败", e); + return AjaxResult.error("读取单元格数据失败: " + e.getMessage()); + } + } + + @PostMapping("/updateCells") + public AjaxResult updateCells(@RequestBody Map params) { + try { + String userId = (String) params.get("userId"); + String fileToken = (String) params.get("fileToken"); + int sheetIdx = params.get("sheetIdx") != null ? Integer.parseInt(params.get("sheetIdx").toString()) : 0; + String range = (String) params.get("range"); + @SuppressWarnings("unchecked") + List> values = (List>) params.get("values"); + if (userId == null || fileToken == null || range == null || values == null || values.isEmpty()) { + return AjaxResult.error("userId、fileToken、range、values 不能为空"); + } + KdocsTokenInfo t = ensureToken(userId); + JSONObject r = kdocsOpenApiService.updateCells(t.getAccessToken(), fileToken, sheetIdx, range, values); + return AjaxResult.success("更新单元格数据成功", r); + } catch (IllegalStateException e) { + return AjaxResult.error(e.getMessage()); + } catch (Exception e) { + log.error("更新单元格失败", e); + return AjaxResult.error("更新单元格数据失败: " + e.getMessage()); + } + } + + @PostMapping("/createSheet") + public AjaxResult createSheet(@RequestBody Map params) { + try { + String userId = (String) params.get("userId"); + String fileToken = (String) params.get("fileToken"); + String sheetName = (String) params.get("sheetName"); + if (userId == null || fileToken == null || sheetName == null) { + return AjaxResult.error("userId、fileToken、sheetName 不能为空"); + } + KdocsTokenInfo t = ensureToken(userId); + return AjaxResult.success("创建数据表成功", kdocsOpenApiService.createSheet(t.getAccessToken(), fileToken, sheetName)); + } catch (IllegalStateException e) { + return AjaxResult.error(e.getMessage()); + } catch (Exception e) { + log.error("创建数据表失败", e); + return AjaxResult.error("创建数据表失败: " + e.getMessage()); + } + } + + @PostMapping("/batchUpdateCells") + public AjaxResult batchUpdateCells(@RequestBody Map params) { + try { + String userId = (String) params.get("userId"); + String fileToken = (String) params.get("fileToken"); + int sheetIdx = params.get("sheetIdx") != null ? Integer.parseInt(params.get("sheetIdx").toString()) : 0; + @SuppressWarnings("unchecked") + List> updates = (List>) params.get("updates"); + if (userId == null || fileToken == null || updates == null || updates.isEmpty()) { + return AjaxResult.error("参数不完整"); + } + KdocsTokenInfo t = ensureToken(userId); + return AjaxResult.success("批量更新成功", kdocsOpenApiService.batchUpdateCells(t.getAccessToken(), fileToken, sheetIdx, updates)); + } catch (IllegalStateException e) { + return AjaxResult.error(e.getMessage()); + } catch (Exception e) { + log.error("批量更新失败", e); + return AjaxResult.error("批量更新单元格数据失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WPS365CallbackController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WPS365CallbackController.java deleted file mode 100644 index 2698fe9..0000000 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WPS365CallbackController.java +++ /dev/null @@ -1,278 +0,0 @@ -package com.ruoyi.web.controller.jarvis; - -import com.ruoyi.common.annotation.Anonymous; -import com.ruoyi.common.core.controller.BaseController; -import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo; -import com.ruoyi.jarvis.service.IWPS365OAuthService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; - -/** - * WPS365 OAuth回调控制器 - * 用于处理OAuth回调,避免前端路由拦截 - * - * @author system - */ -@RestController -@RequestMapping("/wps365-callback") -public class WPS365CallbackController extends BaseController { - - private static final Logger log = LoggerFactory.getLogger(WPS365CallbackController.class); - - @Autowired - private IWPS365OAuthService wps365OAuthService; - - /** - * OAuth回调 - 通过授权码获取访问令牌(GET请求) - * 路径:/wps365-callback - * 注意:在WPS365开放平台只需配置域名:jarvis.van333.cn(不能包含路径) - * 授权URL中的redirect_uri参数会自动使用配置中的完整URL:https://jarvis.van333.cn/wps365-callback - */ - @Anonymous - @GetMapping - public ResponseEntity oauthCallbackGet(@RequestParam(value = "code", required = false) String code, - @RequestParam(value = "state", required = false) String state, - @RequestParam(value = "error", required = false) String error, - @RequestParam(value = "error_description", required = false) String errorDescription, - @RequestParam(value = "challenge", required = false) String challenge) { - return handleOAuthCallback(code, state, error, errorDescription, challenge, null); - } - - /** - * OAuth回调 - 通过授权码获取访问令牌(POST请求) - * WPS365在验证回调URL时会发送POST请求进行challenge验证 - * 支持application/json和application/x-www-form-urlencoded两种格式 - */ - @Anonymous - @PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE, MediaType.TEXT_PLAIN_VALUE}) - public ResponseEntity oauthCallbackPost(@RequestParam(value = "code", required = false) String code, - @RequestParam(value = "state", required = false) String state, - @RequestParam(value = "error", required = false) String error, - @RequestParam(value = "error_description", required = false) String errorDescription, - @RequestParam(value = "challenge", required = false) String challenge, - @RequestBody(required = false) String requestBody) { - log.info("收到WPS365 POST回调请求 - code: {}, challenge: {}, requestBody: {}", - code != null ? "有" : "无", - challenge != null ? challenge : "无", - requestBody != null && requestBody.length() > 0 ? requestBody.substring(0, Math.min(100, requestBody.length())) : "无"); - - // 如果challenge在URL参数中,直接使用 - // 如果不在URL参数中,尝试从请求体中解析(可能是JSON或form-data) - if (challenge == null && requestBody != null && !requestBody.trim().isEmpty()) { - String bodyTrimmed = requestBody.trim(); - // 尝试解析JSON格式 - if (bodyTrimmed.startsWith("{")) { - try { - com.alibaba.fastjson2.JSONObject json = com.alibaba.fastjson2.JSON.parseObject(requestBody); - if (json.containsKey("challenge")) { - challenge = json.getString("challenge"); - log.info("从JSON请求体中解析到challenge: {}", challenge); - } - } catch (Exception e) { - log.debug("解析JSON请求体失败", e); - } - } - // 尝试解析form-urlencoded格式 (challenge=xxx) - else if (bodyTrimmed.contains("challenge=")) { - try { - String[] pairs = bodyTrimmed.split("&"); - for (String pair : pairs) { - if (pair.startsWith("challenge=")) { - challenge = java.net.URLDecoder.decode(pair.substring("challenge=".length()), "UTF-8"); - log.info("从form-urlencoded请求体中解析到challenge: {}", challenge); - break; - } - } - } catch (Exception e) { - log.debug("解析form-urlencoded请求体失败", e); - } - } - // 如果请求体就是challenge值本身(纯文本) - else if (bodyTrimmed.length() < 200) { - challenge = bodyTrimmed; - log.info("将请求体作为challenge值: {}", challenge); - } - } - - return handleOAuthCallback(code, state, error, errorDescription, challenge, requestBody); - } - - /** - * 处理OAuth回调的核心逻辑 - */ - private ResponseEntity handleOAuthCallback(String code, String state, String error, - String errorDescription, String challenge, String requestBody) { - try { - // 处理challenge验证(WPS365后台配置时用于验证回调URL) - if (challenge != null && !challenge.trim().isEmpty()) { - log.info("收到WPS365 challenge验证请求 - challenge: {}", challenge); - // 尝试返回JSON格式(符合OAuth标准) - // 格式:{"challenge": "xxx"} 或直接返回challenge值 - try { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - String jsonResponse = "{\"challenge\":\"" + challenge + "\"}"; - return new ResponseEntity<>(jsonResponse, headers, HttpStatus.OK); - } catch (Exception e) { - log.warn("返回JSON格式失败,尝试纯文本格式", e); - // 如果JSON失败,尝试纯文本 - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_PLAIN); - return new ResponseEntity<>(challenge, headers, HttpStatus.OK); - } - } - - // 处理授权错误 - if (error != null) { - log.error("WPS365授权失败 - error: {}, error_description: {}", error, errorDescription); - String errorMsg = errorDescription != null ? errorDescription : error; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_HTML); - return new ResponseEntity<>(generateCallbackHtml(false, "授权失败: " + errorMsg, null), headers, HttpStatus.OK); - } - - // 如果没有code也没有challenge,可能是直接访问,显示提示信息 - if (code == null || code.trim().isEmpty()) { - log.warn("访问回调地址但没有授权码,可能是测试访问"); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_HTML); - return new ResponseEntity<>(generateCallbackHtml(false, "等待授权回调... 如果没有授权码,请通过授权流程访问", null), headers, HttpStatus.OK); - } - - log.info("收到WPS365授权回调 - code: {}, state: {}", code, state); - - // 使用授权码换取access_token - WPS365TokenInfo tokenInfo = wps365OAuthService.getAccessTokenByCode(code); - - // 验证返回的token信息 - if (tokenInfo == null || tokenInfo.getAccessToken() == null) { - log.error("获取访问令牌失败 - tokenInfo: {}", tokenInfo); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_HTML); - return new ResponseEntity<>(generateCallbackHtml(false, "获取访问令牌失败,响应数据格式不正确", null), headers, HttpStatus.OK); - } - - log.info("成功获取访问令牌 - userId: {}, access_token: {}", - tokenInfo.getUserId(), - tokenInfo.getAccessToken() != null ? tokenInfo.getAccessToken().substring(0, 20) + "..." : "null"); - - // 自动保存token到后端(用 WPS 返回的 userId 存一份,再用 default_user 存一份,便于前端固定用 default_user 查询) - try { - if (tokenInfo.getUserId() != null) { - wps365OAuthService.saveToken(tokenInfo.getUserId(), tokenInfo); - log.info("访问令牌已自动保存到后端缓存 - userId: {}", tokenInfo.getUserId()); - // 同时以 default_user 保存,前端 WPS365 页固定传 userId=default_user,否则会提示未授权 - wps365OAuthService.saveToken("default_user", tokenInfo); - log.info("已同时保存为 default_user,前端可直接使用"); - } else { - log.warn("userId为空,无法保存Token"); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_HTML); - return new ResponseEntity<>(generateCallbackHtml(false, "获取用户ID失败,无法保存Token", null), headers, HttpStatus.OK); - } - } catch (Exception e) { - log.error("保存访问令牌失败", e); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_HTML); - return new ResponseEntity<>(generateCallbackHtml(false, "保存访问令牌失败: " + e.getMessage(), null), headers, HttpStatus.OK); - } - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_HTML); - return new ResponseEntity<>(generateCallbackHtml(true, "授权成功,访问令牌已自动保存", tokenInfo), headers, HttpStatus.OK); - } catch (Exception e) { - log.error("OAuth回调处理失败", e); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_HTML); - return new ResponseEntity<>(generateCallbackHtml(false, "授权失败: " + e.getMessage(), null), headers, HttpStatus.OK); - } - } - - /** - * 生成回调HTML页面 - */ - private String generateCallbackHtml(boolean success, String message, WPS365TokenInfo tokenInfo) { - StringBuilder html = new StringBuilder(); - html.append(""); - html.append(""); - html.append(""); - html.append(""); - html.append(""); - html.append("WPS365授权").append(success ? "成功" : "失败").append(""); - html.append(""); - html.append(""); - html.append(""); - html.append("
"); - if (success) { - html.append("
"); - html.append("

授权成功

"); - } else { - html.append("
"); - html.append("

授权失败

"); - } - html.append("
").append(message).append("
"); - - // 显示Token信息(仅成功时) - if (success && tokenInfo != null) { - html.append("
"); - html.append("授权信息:
"); - if (tokenInfo.getUserId() != null) { - html.append("用户ID: ").append(tokenInfo.getUserId()).append("
"); - } - if (tokenInfo.getExpiresIn() != null) { - html.append("有效期: ").append(tokenInfo.getExpiresIn()).append(" 秒
"); - } - html.append("
"); - } - - html.append("

窗口将在3秒后自动关闭...

"); - html.append("
"); - html.append(""); - html.append(""); - html.append(""); - return html.toString(); - } -} - diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WPS365Controller.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WPS365Controller.java deleted file mode 100644 index 28d0902..0000000 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WPS365Controller.java +++ /dev/null @@ -1,585 +0,0 @@ -package com.ruoyi.web.controller.jarvis; - -import com.alibaba.fastjson2.JSONObject; -import com.ruoyi.common.annotation.Anonymous; -import com.ruoyi.common.core.controller.BaseController; -import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.core.redis.RedisCache; -import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo; -import com.ruoyi.jarvis.service.IWPS365ApiService; -import com.ruoyi.jarvis.service.IWPS365OAuthService; -import com.ruoyi.jarvis.service.impl.WPS365OAuthServiceImpl; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Map; - -/** - * WPS365控制器 - * - * @author system - */ -@RestController -@RequestMapping("/jarvis/wps365") -public class WPS365Controller extends BaseController { - - private static final Logger log = LoggerFactory.getLogger(WPS365Controller.class); - - @Autowired - private IWPS365OAuthService wps365OAuthService; - - @Autowired - private IWPS365ApiService wps365ApiService; - - @Autowired - private WPS365OAuthServiceImpl wps365OAuthServiceImpl; - - @Autowired - private RedisCache redisCache; - - /** - * 获取授权URL - */ - @GetMapping("/authUrl") - public AjaxResult getAuthUrl(@RequestParam(required = false) String state) { - try { - String authUrl = wps365OAuthService.getAuthUrl(state); - return AjaxResult.success("获取授权URL成功", authUrl); - } catch (Exception e) { - log.error("获取授权URL失败", e); - return AjaxResult.error("获取授权URL失败: " + e.getMessage()); - } - } - - /** - * OAuth回调处理(已废弃,请使用 /wps365-callback) - * 保留此接口用于兼容,实际回调请使用 WPS365CallbackController - */ - @Anonymous - @GetMapping("/oauth/callback") - public AjaxResult oauthCallback(@RequestParam String code, - @RequestParam(required = false) String state) { - try { - log.warn("使用已废弃的回调接口 /jarvis/wps365/oauth/callback,建议使用 /wps365-callback"); - log.info("收到OAuth回调 - code: {}, state: {}", code, state); - - // 通过授权码获取访问令牌 - WPS365TokenInfo tokenInfo = wps365OAuthService.getAccessTokenByCode(code); - - // 保存Token到Redis(使用userId作为key) - if (tokenInfo.getUserId() != null) { - wps365OAuthService.saveToken(tokenInfo.getUserId(), tokenInfo); - } - - return AjaxResult.success("授权成功", tokenInfo); - } catch (Exception e) { - log.error("OAuth回调处理失败", e); - return AjaxResult.error("授权失败: " + e.getMessage()); - } - } - - /** - * 刷新访问令牌 - */ - @PostMapping("/refreshToken") - public AjaxResult refreshToken(@RequestBody Map params) { - try { - String refreshToken = (String) params.get("refreshToken"); - if (refreshToken == null || refreshToken.trim().isEmpty()) { - return AjaxResult.error("refreshToken不能为空"); - } - - WPS365TokenInfo tokenInfo = wps365OAuthService.refreshAccessToken(refreshToken); - - // 更新Token到Redis - if (tokenInfo.getUserId() != null) { - wps365OAuthService.saveToken(tokenInfo.getUserId(), tokenInfo); - } - - return AjaxResult.success("刷新令牌成功", tokenInfo); - } catch (Exception e) { - log.error("刷新访问令牌失败", e); - return AjaxResult.error("刷新令牌失败: " + e.getMessage()); - } - } - - /** - * 获取当前用户的Token状态 - * 前端 WPS365 页固定传 userId=default_user;若该 key 下无 token,则尝试用任意已存 token 并写回 default_user,避免“已授权却提示未授权” - */ - @GetMapping("/tokenStatus") - public AjaxResult getTokenStatus(@RequestParam(required = false) String userId) { - try { - WPS365TokenInfo tokenInfo = null; - - if (userId != null && !userId.trim().isEmpty()) { - tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - // 前端固定用 default_user,但回调可能只存了 WPS 返回的 userId(如 wps365_xxx),此处兜底:用任意已存 token 并写回 default_user - if (tokenInfo == null && "default_user".equals(userId)) { - tokenInfo = wps365OAuthService.getCurrentToken(); - if (tokenInfo != null) { - wps365OAuthService.saveToken("default_user", tokenInfo); - log.info("已用已有 WPS365 Token 补写 default_user,前端可正常显示已授权"); - } - } - } else { - tokenInfo = wps365OAuthService.getCurrentToken(); - } - - if (tokenInfo == null) { - JSONObject result = new JSONObject(); - result.put("hasToken", false); - result.put("isValid", false); - return AjaxResult.success("未授权", result); - } - - boolean isValid = wps365OAuthService.isTokenValid(tokenInfo); - JSONObject result = new JSONObject(); - result.put("hasToken", true); - result.put("isValid", isValid); - result.put("userId", tokenInfo.getUserId()); - result.put("expired", tokenInfo.isExpired()); - if (tokenInfo.getExpiresIn() != null) { - result.put("expiresIn", tokenInfo.getExpiresIn()); - } - - return AjaxResult.success("获取Token状态成功", result); - } catch (Exception e) { - log.error("获取Token状态失败", e); - return AjaxResult.error("获取Token状态失败: " + e.getMessage()); - } - } - - /** - * 手动设置Token(用于测试或手动授权) - */ - @PostMapping("/setToken") - public AjaxResult setToken(@RequestBody Map params) { - try { - String accessToken = (String) params.get("accessToken"); - String refreshToken = (String) params.get("refreshToken"); - String userId = (String) params.get("userId"); - Integer expiresIn = params.get("expiresIn") != null ? - Integer.valueOf(params.get("expiresIn").toString()) : 7200; - - if (accessToken == null || accessToken.trim().isEmpty()) { - return AjaxResult.error("accessToken不能为空"); - } - if (userId == null || userId.trim().isEmpty()) { - return AjaxResult.error("userId不能为空"); - } - - WPS365TokenInfo tokenInfo = new WPS365TokenInfo(); - tokenInfo.setAccessToken(accessToken); - tokenInfo.setRefreshToken(refreshToken); - tokenInfo.setExpiresIn(expiresIn); - tokenInfo.setUserId(userId); - - wps365OAuthService.saveToken(userId, tokenInfo); - - return AjaxResult.success("设置Token成功"); - } catch (Exception e) { - log.error("设置Token失败", e); - return AjaxResult.error("设置Token失败: " + e.getMessage()); - } - } - - /** - * 获取用户信息 - */ - @GetMapping("/userInfo") - public AjaxResult getUserInfo(@RequestParam String userId) { - try { - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效,如果过期则尝试刷新 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - JSONObject userInfo = wps365ApiService.getUserInfo(tokenInfo.getAccessToken()); - if (userInfo == null) { - // WPS 未提供可用的“当前用户信息”接口或路径变更,降级返回 token 中的 user_id,避免 500 - userInfo = new JSONObject(); - userInfo.put("user_id", tokenInfo.getUserId()); - userInfo.put("name", "已授权用户"); - userInfo.put("email", "-"); - } - return AjaxResult.success("获取用户信息成功", userInfo); - } catch (Exception e) { - log.error("获取用户信息失败", e); - return AjaxResult.error("获取用户信息失败: " + e.getMessage()); - } - } - - /** - * 获取文件列表 - */ - @GetMapping("/files") - public AjaxResult getFileList(@RequestParam String userId, - @RequestParam(required = false, defaultValue = "1") Integer page, - @RequestParam(required = false, defaultValue = "20") Integer pageSize) { - try { - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效,如果过期则尝试刷新 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - Map params = new java.util.HashMap<>(); - params.put("page", page); - params.put("page_size", pageSize); - - JSONObject fileList = wps365ApiService.getFileList(tokenInfo.getAccessToken(), params); - return AjaxResult.success("获取文件列表成功", fileList); - } catch (Exception e) { - log.error("获取文件列表失败", e); - return AjaxResult.error("获取文件列表失败: " + e.getMessage()); - } - } - - /** - * 获取文件信息 - */ - @GetMapping("/fileInfo") - public AjaxResult getFileInfo(@RequestParam String userId, - @RequestParam String fileToken) { - try { - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - JSONObject fileInfo = wps365ApiService.getFileInfo(tokenInfo.getAccessToken(), fileToken); - return AjaxResult.success("获取文件信息成功", fileInfo); - } catch (Exception e) { - log.error("获取文件信息失败 - fileToken: {}", fileToken, e); - return AjaxResult.error("获取文件信息失败: " + e.getMessage()); - } - } - - /** - * 获取工作表列表 - */ - @GetMapping("/sheets") - public AjaxResult getSheetList(@RequestParam String userId, - @RequestParam String fileToken) { - try { - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - JSONObject sheetList = wps365ApiService.getSheetList(tokenInfo.getAccessToken(), fileToken); - return AjaxResult.success("获取工作表列表成功", sheetList); - } catch (Exception e) { - log.error("获取工作表列表失败 - fileToken: {}", fileToken, e); - return AjaxResult.error("获取工作表列表失败: " + e.getMessage()); - } - } - - /** - * 读取单元格数据 - */ - @GetMapping("/readCells") - public AjaxResult readCells(@RequestParam String userId, - @RequestParam String fileToken, - @RequestParam(defaultValue = "0") int sheetIdx, - @RequestParam(required = false) String range) { - try { - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - JSONObject result = wps365ApiService.readCells(tokenInfo.getAccessToken(), fileToken, sheetIdx, range); - return AjaxResult.success("读取单元格数据成功", result); - } catch (Exception e) { - log.error("读取单元格数据失败 - fileToken: {}, sheetIdx: {}, range: {}", fileToken, sheetIdx, range, e); - return AjaxResult.error("读取单元格数据失败: " + e.getMessage()); - } - } - - /** - * 更新单元格数据 - */ - @PostMapping("/updateCells") - public AjaxResult updateCells(@RequestBody Map params) { - try { - String userId = (String) params.get("userId"); - String fileToken = (String) params.get("fileToken"); - Integer sheetIdx = params.get("sheetIdx") != null ? - Integer.valueOf(params.get("sheetIdx").toString()) : 0; - String range = (String) params.get("range"); - @SuppressWarnings("unchecked") - List> values = (List>) params.get("values"); - - if (userId == null || userId.trim().isEmpty()) { - return AjaxResult.error("userId不能为空"); - } - if (fileToken == null || fileToken.trim().isEmpty()) { - return AjaxResult.error("fileToken不能为空"); - } - if (range == null || range.trim().isEmpty()) { - return AjaxResult.error("range不能为空"); - } - if (values == null || values.isEmpty()) { - return AjaxResult.error("values不能为空"); - } - - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - JSONObject result = wps365ApiService.updateCells( - tokenInfo.getAccessToken(), - fileToken, - sheetIdx, - range, - values - ); - return AjaxResult.success("更新单元格数据成功", result); - } catch (Exception e) { - log.error("更新单元格数据失败", e); - return AjaxResult.error("更新单元格数据失败: " + e.getMessage()); - } - } - - /** - * 创建数据表 - */ - @PostMapping("/createSheet") - public AjaxResult createSheet(@RequestBody Map params) { - try { - String userId = (String) params.get("userId"); - String fileToken = (String) params.get("fileToken"); - String sheetName = (String) params.get("sheetName"); - - if (userId == null || userId.trim().isEmpty()) { - return AjaxResult.error("userId不能为空"); - } - if (fileToken == null || fileToken.trim().isEmpty()) { - return AjaxResult.error("fileToken不能为空"); - } - if (sheetName == null || sheetName.trim().isEmpty()) { - return AjaxResult.error("sheetName不能为空"); - } - - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - JSONObject result = wps365ApiService.createSheet(tokenInfo.getAccessToken(), fileToken, sheetName); - return AjaxResult.success("创建数据表成功", result); - } catch (Exception e) { - log.error("创建数据表失败", e); - return AjaxResult.error("创建数据表失败: " + e.getMessage()); - } - } - - /** - * 批量更新单元格数据 - */ - @PostMapping("/batchUpdateCells") - public AjaxResult batchUpdateCells(@RequestBody Map params) { - try { - String userId = (String) params.get("userId"); - String fileToken = (String) params.get("fileToken"); - Integer sheetIdx = params.get("sheetIdx") != null ? - Integer.valueOf(params.get("sheetIdx").toString()) : 0; - @SuppressWarnings("unchecked") - List> updates = (List>) params.get("updates"); - - if (userId == null || userId.trim().isEmpty()) { - return AjaxResult.error("userId不能为空"); - } - if (fileToken == null || fileToken.trim().isEmpty()) { - return AjaxResult.error("fileToken不能为空"); - } - if (updates == null || updates.isEmpty()) { - return AjaxResult.error("updates不能为空"); - } - - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - JSONObject result = wps365ApiService.batchUpdateCells( - tokenInfo.getAccessToken(), - fileToken, - sheetIdx, - updates - ); - return AjaxResult.success("批量更新单元格数据成功", result); - } catch (Exception e) { - log.error("批量更新单元格数据失败", e); - return AjaxResult.error("批量更新单元格数据失败: " + e.getMessage()); - } - } - - /** - * 读取AirSheet工作表数据 - */ - @GetMapping("/readAirSheetCells") - public AjaxResult readAirSheetCells(@RequestParam String userId, - @RequestParam String fileId, - @RequestParam(required = false, defaultValue = "0") String worksheetId, - @RequestParam(required = false) String range) { - try { - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - JSONObject result = wps365ApiService.readAirSheetCells(tokenInfo.getAccessToken(), fileId, worksheetId, range); - return AjaxResult.success("读取AirSheet数据成功", result); - } catch (Exception e) { - log.error("读取AirSheet数据失败 - fileId: {}, worksheetId: {}, range: {}", fileId, worksheetId, range, e); - return AjaxResult.error("读取AirSheet数据失败: " + e.getMessage()); - } - } - - /** - * 更新AirSheet工作表数据 - */ - @PostMapping("/updateAirSheetCells") - public AjaxResult updateAirSheetCells(@RequestBody Map params) { - try { - String userId = (String) params.get("userId"); - String fileId = (String) params.get("fileId"); - String worksheetId = params.get("worksheetId") != null ? params.get("worksheetId").toString() : "0"; - String range = (String) params.get("range"); - @SuppressWarnings("unchecked") - List> values = (List>) params.get("values"); - - if (userId == null || fileId == null) { - return AjaxResult.error("userId和fileId不能为空"); - } - - WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId); - if (tokenInfo == null) { - return AjaxResult.error("用户未授权,请先完成授权"); - } - - // 检查Token是否有效 - if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) { - try { - tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken()); - wps365OAuthService.saveToken(userId, tokenInfo); - } catch (Exception e) { - log.error("刷新Token失败", e); - return AjaxResult.error("Token已过期且刷新失败,请重新授权"); - } - } - - JSONObject result = wps365ApiService.updateAirSheetCells(tokenInfo.getAccessToken(), fileId, worksheetId, range, values); - return AjaxResult.success("更新AirSheet数据成功", result); - } catch (Exception e) { - log.error("更新AirSheet数据失败", e); - return AjaxResult.error("更新AirSheet数据失败: " + e.getMessage()); - } - } -} - diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index f1b9bf1..04a02ee 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -9,46 +9,13 @@ tencent: push: # 延迟时间(分钟),默认10分钟 minutes: 10 -# WPS365开放平台配置 -# 文档地址:https://open.wps.cn/ -wps365: - # 应用ID(AppId)- 需要在WPS365开放平台申请 - app-id: AK20260114NNQJKV - # 应用密钥(AppKey)- 需要在WPS365开放平台申请,注意保密 - app-key: 4c58bc1642e5e8fa731f75af9370496a - # 授权回调地址(需要在WPS365开放平台配置授权域名) - # 注意:使用 /wps365-callback 路径,避免前端路由拦截 - redirect-uri: https://jarvis.van333.cn/wps365-callback - # API基础地址 - api-base-url: https://openapi.wps.cn/api/v1 - # OAuth授权地址(正确格式:https://openapi.wps.cn/oauth2/auth) - oauth-url: https://openapi.wps.cn/oauth2/auth - # 获取Token地址 - token-url: https://openapi.wps.cn/oauth2/token - # 刷新Token地址 - refresh-token-url: https://openapi.wps.cn/oauth2/token - # OAuth授权请求的scope权限(可选) - # 如果不配置,默认使用:kso.file.readwrite(文件读写权限,支持在线表格操作) - # - # ⚠️ 重要:如果报错invalid_scope,必须按以下步骤操作: - # 1. 登录WPS365开放平台:https://open.wps.cn/ - # 2. 进入"开发配置" > "权限管理" - # 3. 查看已申请权限的准确名称(必须以 kso. 开头) - # 4. 在下方配置scope,使用英文逗号分隔(WPS365官方要求) - # - # 根据WPS365官方文档(https://open.wps.cn/documents/app-integration-dev/wps365/server/): - # - 必须使用英文逗号分隔(不是空格) - # - 权限名称必须以 kso. 开头,格式如:kso.file.read, kso.file.readwrite - # - 常见权限名称: - # * kso.file.read (文件读取) - # * kso.file.readwrite (文件读写,支持在线表格操作) - # * kso.doclib.readwrite (文档库读写) - # * kso.wiki.readwrite (知识库读写) - # - # 示例配置(根据平台后台实际显示的权限名称修改): - # scope: kso.file.readwrite - # scope: kso.file.read,kso.file.readwrite - # scope: kso.doclib.readwrite - # 后端写入「智能表格」时,授权必须包含 kso.airsheet.readwrite,例如: - # scope: kso.file.readwrite,kso.airsheet.readwrite - scope: kso.file.readwrite,kso.airsheet.readwrite +# 金山文档开放平台(个人云)https://developer.kdocs.cn +kdocs: + api-host: https://developer.kdocs.cn + # 在开发者后台创建应用后填写 app_id / app_key + app-id: "" + app-key: "" + # 与后台登记的回调一致,建议使用独立路径(勿被前端路由拦截) + redirect-uri: https://jarvis.van333.cn/kdocs-callback + # 逗号分隔,须与应用申请权限一致:https://developer.kdocs.cn/server/guide/permission.html + scope: user_basic,access_personal_files,edit_personal_files diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/KdocsCloudConfig.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/KdocsCloudConfig.java new file mode 100644 index 0000000..cd3968c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/KdocsCloudConfig.java @@ -0,0 +1,98 @@ +package com.ruoyi.jarvis.config; + +import com.ruoyi.common.utils.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; + +/** + * 金山文档开放平台(个人云)配置 + * + * @see developer.kdocs.cn + */ +@Configuration +@Component +@ConfigurationProperties(prefix = "kdocs") +public class KdocsCloudConfig { + + private static final Logger log = LoggerFactory.getLogger(KdocsCloudConfig.class); + + /** API 与授权页主机,默认 https://developer.kdocs.cn */ + private String apiHost = "https://developer.kdocs.cn"; + + /** 应用 app_id */ + private String appId; + + /** 应用 app_key */ + private String appKey; + + /** OAuth 回调地址(须在开发者后台登记) */ + private String redirectUri; + + /** + * 授权 scope,逗号分隔。 + * 参考:https://developer.kdocs.cn/server/guide/permission.html + */ + private String scope = "user_basic,access_personal_files,edit_personal_files"; + + @PostConstruct + public void init() { + log.info("Kdocs 配置加载 - apiHost: {}, redirectUri: {}, appId: {}", + apiHost, + redirectUri, + appId != null && appId.length() > 8 ? appId.substring(0, 8) + "..." : appId); + if (StringUtils.isBlank(appId)) { + log.warn("kdocs.app-id 未配置,请在 application.yml 中填写金山文档开放平台应用信息"); + } + if (StringUtils.isBlank(appKey)) { + log.warn("kdocs.app-key 未配置"); + } + if (StringUtils.isBlank(redirectUri)) { + log.warn("kdocs.redirect-uri 未配置"); + } + } + + public String getApiHost() { + return apiHost; + } + + public void setApiHost(String apiHost) { + this.apiHost = apiHost; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getAppKey() { + return appKey; + } + + public void setAppKey(String appKey) { + this.appKey = appKey; + } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/WPS365Config.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/WPS365Config.java deleted file mode 100644 index 06eb4e0..0000000 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/WPS365Config.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.ruoyi.jarvis.config; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; -import org.springframework.stereotype.Component; - -import javax.annotation.PostConstruct; - -/** - * WPS365开放平台配置 - * - * @author system - */ -@Configuration -@Component -@ConfigurationProperties(prefix = "wps365") -public class WPS365Config { - - private static final Logger log = LoggerFactory.getLogger(WPS365Config.class); - - /** 应用ID(AppId) */ - private String appId; - - /** 应用密钥(AppKey) */ - private String appKey; - - /** 授权回调地址 */ - private String redirectUri; - - /** API基础地址 */ - private String apiBaseUrl = "https://openapi.wps.cn/api/v1"; - - /** OAuth授权地址 */ - private String oauthUrl = "https://openapi.wps.cn/oauth2/auth"; - - /** 获取Token地址 */ - private String tokenUrl = "https://openapi.wps.cn/oauth2/token"; - - /** 刷新Token地址 */ - private String refreshTokenUrl = "https://openapi.wps.cn/oauth2/token"; - - /** OAuth授权请求的scope权限(可选,如果不配置则使用默认值) */ - private String scope; - - /** - * 配置初始化后验证 - */ - @PostConstruct - public void init() { - log.info("WPS365配置加载 - appId: {}, redirectUri: {}, apiBaseUrl: {}", - appId != null && appId.length() > 10 ? appId.substring(0, 10) + "..." : (appId != null ? appId : "null"), - redirectUri != null ? redirectUri : "null", - apiBaseUrl); - - if (appId == null || appId.trim().isEmpty()) { - log.warn("WPS365应用ID未配置!请检查application.yml中的wps365.app-id"); - } - if (appKey == null || appKey.trim().isEmpty()) { - log.warn("WPS365应用密钥未配置!请检查application.yml中的wps365.app-key"); - } - if (redirectUri == null || redirectUri.trim().isEmpty()) { - log.warn("WPS365回调地址未配置!请检查application.yml中的wps365.redirect-uri"); - } - } - - public String getAppId() { - return appId; - } - - public void setAppId(String appId) { - this.appId = appId; - } - - public String getAppKey() { - return appKey; - } - - public void setAppKey(String appKey) { - this.appKey = appKey; - } - - public String getRedirectUri() { - return redirectUri; - } - - public void setRedirectUri(String redirectUri) { - this.redirectUri = redirectUri; - } - - public String getApiBaseUrl() { - return apiBaseUrl; - } - - public void setApiBaseUrl(String apiBaseUrl) { - this.apiBaseUrl = apiBaseUrl; - } - - public String getOauthUrl() { - return oauthUrl; - } - - public void setOauthUrl(String oauthUrl) { - this.oauthUrl = oauthUrl; - } - - public String getTokenUrl() { - return tokenUrl; - } - - public void setTokenUrl(String tokenUrl) { - this.tokenUrl = tokenUrl; - } - - public String getRefreshTokenUrl() { - return refreshTokenUrl; - } - - public void setRefreshTokenUrl(String refreshTokenUrl) { - this.refreshTokenUrl = refreshTokenUrl; - } - - public String getScope() { - return scope; - } - - public void setScope(String scope) { - this.scope = scope; - } -} - diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WPS365TokenInfo.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/KdocsTokenInfo.java similarity index 74% rename from ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WPS365TokenInfo.java rename to ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/KdocsTokenInfo.java index 50e571e..5607501 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WPS365TokenInfo.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/KdocsTokenInfo.java @@ -3,39 +3,33 @@ package com.ruoyi.jarvis.domain.dto; import java.io.Serializable; /** - * WPS365 Token信息 - * - * @author system + * 金山文档 OAuth 令牌(存 Redis) */ -public class WPS365TokenInfo implements Serializable { - +public class KdocsTokenInfo implements Serializable { + private static final long serialVersionUID = 1L; - - /** 访问令牌 */ + private String accessToken; - - /** 刷新令牌 */ private String refreshToken; - - /** 令牌类型 */ private String tokenType; - - /** 过期时间(秒) */ private Integer expiresIn; - - /** 作用域 */ private String scope; - - /** 用户ID */ + /** 本系统用于关联 Redis 的用户标识,通常取金山 open_id */ private String userId; - - /** 创建时间戳(毫秒) */ private Long createTime; - public WPS365TokenInfo() { + public KdocsTokenInfo() { this.createTime = System.currentTimeMillis(); } + public boolean isExpired() { + if (expiresIn == null || createTime == null) { + return true; + } + long expireTime = createTime + (expiresIn * 1000L); + return System.currentTimeMillis() >= (expireTime - 5 * 60 * 1000L); + } + public String getAccessToken() { return accessToken; } @@ -91,18 +85,4 @@ public class WPS365TokenInfo implements Serializable { public void setCreateTime(Long createTime) { this.createTime = createTime; } - - /** - * 检查token是否过期 - */ - public boolean isExpired() { - if (expiresIn == null || createTime == null) { - return true; - } - long currentTime = System.currentTimeMillis(); - long expireTime = createTime + (expiresIn * 1000L); - // 提前5分钟认为过期,留出刷新时间 - return currentTime >= (expireTime - 5 * 60 * 1000); - } } - diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IKdocsOAuthService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IKdocsOAuthService.java new file mode 100644 index 0000000..7b0c786 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IKdocsOAuthService.java @@ -0,0 +1,20 @@ +package com.ruoyi.jarvis.service; + +import com.ruoyi.jarvis.domain.dto.KdocsTokenInfo; + +public interface IKdocsOAuthService { + + String getAuthUrl(String state); + + KdocsTokenInfo getAccessTokenByCode(String code); + + KdocsTokenInfo refreshAccessToken(String refreshToken, String existingUserId); + + KdocsTokenInfo getCurrentToken(); + + void saveToken(String userId, KdocsTokenInfo tokenInfo); + + void clearToken(String userId); + + boolean isTokenValid(KdocsTokenInfo tokenInfo); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IKdocsOpenApiService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IKdocsOpenApiService.java new file mode 100644 index 0000000..4301e17 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IKdocsOpenApiService.java @@ -0,0 +1,28 @@ +package com.ruoyi.jarvis.service; + +import com.alibaba.fastjson2.JSONObject; + +import java.util.List; +import java.util.Map; + +/** + * 金山文档个人云 Open API(developer.kdocs.cn) + */ +public interface IKdocsOpenApiService { + + JSONObject getUserInfoFlat(String accessToken); + + JSONObject getFileList(String accessToken, Map params); + + JSONObject getFileInfo(String accessToken, String fileToken); + + JSONObject getSheetList(String accessToken, String fileToken); + + JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range); + + JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List> values); + + JSONObject createSheet(String accessToken, String fileToken, String sheetName); + + JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List> updates); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWPS365ApiService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWPS365ApiService.java deleted file mode 100644 index a339890..0000000 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWPS365ApiService.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.ruoyi.jarvis.service; - -import com.alibaba.fastjson2.JSONObject; - -import java.util.List; -import java.util.Map; - -/** - * WPS365 API服务接口 - * - * @author system - */ -public interface IWPS365ApiService { - - /** - * 获取用户信息 - * - * @param accessToken 访问令牌 - * @return 用户信息 - */ - JSONObject getUserInfo(String accessToken); - - /** - * 获取文件列表 - * - * @param accessToken 访问令牌 - * @param params 查询参数(page, page_size等) - * @return 文件列表 - */ - JSONObject getFileList(String accessToken, Map params); - - /** - * 获取文件信息 - * - * @param accessToken 访问令牌 - * @param fileToken 文件token - * @return 文件信息 - */ - JSONObject getFileInfo(String accessToken, String fileToken); - - /** - * 更新单元格数据(KSheet - 在线表格) - * - * @param accessToken 访问令牌 - * @param fileToken 文件token - * @param sheetIdx 工作表索引(从0开始) - * @param range 单元格范围(如:A1:B2) - * @param values 单元格值(二维数组,第一维是行,第二维是列) - * @return 更新结果 - */ - JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List> values); - - /** - * 读取单元格数据 - * - * @param accessToken 访问令牌 - * @param fileToken 文件token - * @param sheetIdx 工作表索引 - * @param range 单元格范围 - * @return 单元格数据 - */ - JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range); - - /** - * 获取工作表列表 - * - * @param accessToken 访问令牌 - * @param fileToken 文件token - * @return 工作表列表 - */ - JSONObject getSheetList(String accessToken, String fileToken); - - /** - * 创建数据表 - * - * @param accessToken 访问令牌 - * @param fileToken 文件token - * @param sheetName 工作表名称 - * @return 创建结果 - */ - JSONObject createSheet(String accessToken, String fileToken, String sheetName); - - /** - * 批量更新单元格数据 - * - * @param accessToken 访问令牌 - * @param fileToken 文件token - * @param sheetIdx 工作表索引 - * @param updates 更新列表,每个元素包含range和values - * @return 更新结果 - */ - JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List> updates); - - /** - * 读取AirSheet工作表数据 - * - * @param accessToken 访问令牌 - * @param fileId 文件ID - * @param worksheetId 工作表ID(整数,通常为0表示第一个工作表) - * @param range 单元格范围(如:A1:B10,可选) - * @return 单元格数据 - */ - JSONObject readAirSheetCells(String accessToken, String fileId, String worksheetId, String range); - - /** - * 更新AirSheet工作表数据 - * - * @param accessToken 访问令牌 - * @param fileId 文件ID - * @param worksheetId 工作表ID(整数,通常为0表示第一个工作表) - * @param range 单元格范围(如:A1:B2) - * @param values 单元格值(二维数组,第一维是行,第二维是列) - * @return 更新结果 - */ - JSONObject updateAirSheetCells(String accessToken, String fileId, String worksheetId, String range, List> values); -} - diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWPS365OAuthService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWPS365OAuthService.java deleted file mode 100644 index 5897989..0000000 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWPS365OAuthService.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.ruoyi.jarvis.service; - -import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo; - -/** - * WPS365 OAuth授权服务接口 - * - * @author system - */ -public interface IWPS365OAuthService { - - /** - * 获取授权URL - * - * @param state 状态参数(可选,用于防止CSRF攻击) - * @return 授权URL - */ - String getAuthUrl(String state); - - /** - * 通过授权码获取访问令牌 - * - * @param code 授权码 - * @return Token信息 - */ - WPS365TokenInfo getAccessTokenByCode(String code); - - /** - * 刷新访问令牌 - * - * @param refreshToken 刷新令牌 - * @return 新的Token信息 - */ - WPS365TokenInfo refreshAccessToken(String refreshToken); - - /** - * 获取当前用户的访问令牌 - * - * @return Token信息,如果未授权则返回null - */ - WPS365TokenInfo getCurrentToken(); - - /** - * 保存用户令牌信息 - * - * @param userId 用户ID - * @param tokenInfo Token信息 - */ - void saveToken(String userId, WPS365TokenInfo tokenInfo); - - /** - * 清除用户令牌信息 - * - * @param userId 用户ID - */ - void clearToken(String userId); - - /** - * 检查令牌是否有效 - * - * @param tokenInfo Token信息 - * @return true表示有效,false表示需要刷新或重新授权 - */ - boolean isTokenValid(WPS365TokenInfo tokenInfo); -} - diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/KdocsOAuthServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/KdocsOAuthServiceImpl.java new file mode 100644 index 0000000..3ca9555 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/KdocsOAuthServiceImpl.java @@ -0,0 +1,176 @@ +package com.ruoyi.jarvis.service.impl; + +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.common.core.redis.RedisCache; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.jarvis.config.KdocsCloudConfig; +import com.ruoyi.jarvis.domain.dto.KdocsTokenInfo; +import com.ruoyi.jarvis.service.IKdocsOAuthService; +import com.ruoyi.jarvis.service.IKdocsOpenApiService; +import com.ruoyi.jarvis.util.KdocsOpenApiClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.net.URLEncoder; +import java.io.UnsupportedEncodingException; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Service +public class KdocsOAuthServiceImpl implements IKdocsOAuthService { + + private static final Logger log = LoggerFactory.getLogger(KdocsOAuthServiceImpl.class); + private static final String TOKEN_KEY_PREFIX = "kdocs:token:"; + private static final long TOKEN_EXPIRE_SECONDS = 30L * 24 * 60 * 60; + + @Autowired + private KdocsCloudConfig kdocsCloudConfig; + @Autowired + private RedisCache redisCache; + @Autowired + private IKdocsOpenApiService kdocsOpenApiService; + + private static String enc(String s) { + try { + return URLEncoder.encode(s == null ? "" : s, "UTF-8").replace("+", "%20"); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + @Override + public String getAuthUrl(String state) { + if (StringUtils.isBlank(kdocsCloudConfig.getAppId())) { + throw new RuntimeException("kdocs.app-id 未配置"); + } + if (StringUtils.isBlank(kdocsCloudConfig.getRedirectUri())) { + throw new RuntimeException("kdocs.redirect-uri 未配置"); + } + String redirect = kdocsCloudConfig.getRedirectUri().trim(); + if (redirect.endsWith("/") && redirect.length() > "https://".length()) { + redirect = redirect.substring(0, redirect.length() - 1); + } + if (StringUtils.isBlank(state)) { + state = UUID.randomUUID().toString(); + } + String scope = kdocsCloudConfig.getScope() != null ? kdocsCloudConfig.getScope().trim() : "user_basic,access_personal_files,edit_personal_files"; + return kdocsCloudConfig.getApiHost().replaceAll("/$", "") + + "/h5/auth?app_id=" + enc(kdocsCloudConfig.getAppId()) + + "&scope=" + enc(scope) + + "&redirect_uri=" + enc(redirect) + + "&state=" + enc(state); + } + + @Override + public KdocsTokenInfo getAccessTokenByCode(String code) { + String host = kdocsCloudConfig.getApiHost().replaceAll("/$", ""); + String url = host + "/api/v1/oauth2/access_token?code=" + enc(code) + + "&app_id=" + enc(kdocsCloudConfig.getAppId()) + + "&app_key=" + enc(kdocsCloudConfig.getAppKey()); + JSONObject root = KdocsOpenApiClient.exchange("GET", url, null, true); + JSONObject data = KdocsOpenApiClient.requireBusinessData(root); + + KdocsTokenInfo info = new KdocsTokenInfo(); + info.setAccessToken(data.getString("access_token")); + info.setRefreshToken(data.getString("refresh_token")); + info.setExpiresIn(data.getInteger("expires_in")); + + String openId = null; + try { + JSONObject user = kdocsOpenApiService.getUserInfoFlat(info.getAccessToken()); + openId = user != null ? user.getString("open_id") : null; + if (openId == null && user != null) { + openId = user.getString("user_id"); + } + } catch (Exception e) { + log.warn("换取 token 后拉取用户信息失败: {}", e.getMessage()); + } + if (StringUtils.isBlank(openId)) { + String at = info.getAccessToken(); + openId = at != null && at.length() > 12 ? "kdocs_" + at.substring(0, 12) : "kdocs_default"; + } + info.setUserId(openId); + return info; + } + + @Override + public KdocsTokenInfo refreshAccessToken(String refreshToken, String existingUserId) { + String host = kdocsCloudConfig.getApiHost().replaceAll("/$", ""); + String url = host + "/api/v1/oauth2/refresh_token?app_id=" + enc(kdocsCloudConfig.getAppId()); + JSONObject body = new JSONObject(); + body.put("app_key", kdocsCloudConfig.getAppKey()); + body.put("refresh_token", refreshToken); + JSONObject root = KdocsOpenApiClient.exchange("POST", url, body.toJSONString(), false); + JSONObject data = KdocsOpenApiClient.requireBusinessData(root); + + KdocsTokenInfo info = new KdocsTokenInfo(); + info.setAccessToken(data.getString("access_token")); + info.setRefreshToken(data.getString("refresh_token")); + info.setExpiresIn(data.getInteger("expires_in")); + info.setUserId(existingUserId); + return info; + } + + @Override + public KdocsTokenInfo getCurrentToken() { + try { + String pattern = TOKEN_KEY_PREFIX + "*"; + Collection keys = redisCache.keys(pattern); + if (keys != null) { + for (String key : keys) { + KdocsTokenInfo t = redisCache.getCacheObject(key); + if (t != null && isTokenValid(t)) { + return t; + } + } + for (String key : keys) { + KdocsTokenInfo t = redisCache.getCacheObject(key); + if (t != null) { + return t; + } + } + } + } catch (Exception e) { + log.warn("扫描 kdocs token 失败: {}", e.getMessage()); + } + return null; + } + + @Override + public void saveToken(String userId, KdocsTokenInfo tokenInfo) { + if (StringUtils.isBlank(userId)) { + throw new IllegalArgumentException("userId 不能为空"); + } + if (tokenInfo == null || tokenInfo.getAccessToken() == null) { + throw new IllegalArgumentException("token 不能为空"); + } + redisCache.setCacheObject(TOKEN_KEY_PREFIX + userId, tokenInfo, (int) TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS); + log.info("已保存 Kdocs token userId={}", userId); + } + + @Override + public void clearToken(String userId) { + if (StringUtils.isBlank(userId)) { + return; + } + redisCache.deleteObject(TOKEN_KEY_PREFIX + userId); + } + + @Override + public boolean isTokenValid(KdocsTokenInfo tokenInfo) { + return tokenInfo != null + && tokenInfo.getAccessToken() != null + && StringUtils.isNotBlank(tokenInfo.getAccessToken()) + && !tokenInfo.isExpired(); + } + + public KdocsTokenInfo getTokenByUserId(String userId) { + if (StringUtils.isBlank(userId)) { + return null; + } + return redisCache.getCacheObject(TOKEN_KEY_PREFIX + userId); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/KdocsOpenApiServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/KdocsOpenApiServiceImpl.java new file mode 100644 index 0000000..3b4a2ab --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/KdocsOpenApiServiceImpl.java @@ -0,0 +1,214 @@ +package com.ruoyi.jarvis.service.impl; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.jarvis.config.KdocsCloudConfig; +import com.ruoyi.jarvis.service.IKdocsOpenApiService; +import com.ruoyi.jarvis.util.KdocsOpenApiClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.List; +import java.util.Map; + +@Service +public class KdocsOpenApiServiceImpl implements IKdocsOpenApiService { + + @Autowired + private KdocsCloudConfig kdocsCloudConfig; + + private static String enc(String s) { + try { + return URLEncoder.encode(s == null ? "" : s, "UTF-8").replace("+", "%20"); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + private String host() { + return kdocsCloudConfig.getApiHost().replaceAll("/$", ""); + } + + private JSONObject getOpenApi(String pathWithLeadingSlash, String accessToken) { + String url = host() + pathWithLeadingSlash + + (pathWithLeadingSlash.contains("?") ? "&" : "?") + + "access_token=" + enc(accessToken); + JSONObject root = KdocsOpenApiClient.exchange("GET", url, null, false); + return KdocsOpenApiClient.requireBusinessData(root); + } + + private JSONObject postOpenApi(String pathWithLeadingSlash, String accessToken, String jsonBody) { + String url = host() + pathWithLeadingSlash + + (pathWithLeadingSlash.contains("?") ? "&" : "?") + + "access_token=" + enc(accessToken); + JSONObject root = KdocsOpenApiClient.exchange("POST", url, jsonBody, false); + return KdocsOpenApiClient.requireBusinessData(root); + } + + @Override + public JSONObject getUserInfoFlat(String accessToken) { + JSONObject data = getOpenApi("/api/v1/openapi/user/basic", accessToken); + JSONObject flat = new JSONObject(); + JSONObject id = data.getJSONObject("id"); + if (id != null) { + flat.put("open_id", id.getString("open_id")); + flat.put("user_id", id.getString("open_id")); + } + flat.put("name", data.getString("nickname")); + flat.put("nickname", data.getString("nickname")); + flat.put("avatar", data.getString("avatar")); + return flat; + } + + @Override + public JSONObject getFileList(String accessToken, Map params) { + int count = 20; + if (params != null) { + if (params.get("page_size") != null) { + count = ((Number) params.get("page_size")).intValue(); + } else if (params.get("pageSize") != null) { + count = ((Number) params.get("pageSize")).intValue(); + } + } + StringBuilder path = new StringBuilder("/api/v1/openapi/personal/files/flat?count=") + .append(count) + .append("&order=desc&order_by=mtime&ignore=group"); + if (params != null) { + Object off = params.get("next_offset"); + if (off == null) { + off = params.get("cursor"); + } + if (off != null) { + path.append("&offset=").append(off.toString()); + } + Object nf = params.get("next_filter"); + if (nf != null && StringUtils.isNotBlank(nf.toString())) { + path.append("&filter=").append(enc(nf.toString())); + } + } + JSONObject data = getOpenApi(path.toString(), accessToken); + JSONArray files = data.getJSONArray("files"); + JSONArray normalized = new JSONArray(); + if (files != null) { + for (int i = 0; i < files.size(); i++) { + JSONObject f = files.getJSONObject(i); + JSONObject id = f.getJSONObject("id"); + String openId = id != null ? id.getString("open_id") : null; + JSONObject row = new JSONObject(); + row.put("file_token", openId); + row.put("file_name", f.getString("fname")); + row.put("file_type", f.getString("ftype")); + row.put("mtime", f.get("mtime")); + normalized.add(row); + } + } + JSONObject out = new JSONObject(); + out.put("files", normalized); + out.put("next_offset", data.get("next_offset")); + out.put("next_filter", data.getString("next_filter")); + Integer no = data.getInteger("next_offset"); + out.put("has_more", no != null && no != -1); + return out; + } + + @Override + public JSONObject getFileInfo(String accessToken, String fileToken) { + String path = "/api/v1/openapi/personal/files/" + enc(fileToken) + "/simple/info"; + return getOpenApi(path, accessToken); + } + + @Override + public JSONObject getSheetList(String accessToken, String fileToken) { + String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets"; + JSONObject data = getOpenApi(path, accessToken); + JSONArray infos = data.getJSONArray("sheets_info"); + JSONArray sheets = new JSONArray(); + if (infos != null) { + for (int i = 0; i < infos.size(); i++) { + JSONObject si = infos.getJSONObject(i); + JSONObject s = new JSONObject(); + s.put("name", si.getString("sheet_name")); + s.put("sheet_idx", si.getInteger("sheet_idx")); + s.put("sheet_id", si.get("sheet_id")); + s.put("sheet_type", si.getString("sheet_type")); + sheets.add(s); + } + } + JSONObject out = new JSONObject(); + out.put("sheets", sheets); + out.put("sheets_info", infos); + return out; + } + + @Override + public JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range) { + String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets/" + sheetIdx + "/cells"; + if (StringUtils.isNotBlank(range)) { + path += "?range=" + enc(range.trim()); + } + return getOpenApi(path, accessToken); + } + + @Override + public JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List> values) { + JSONObject body = new JSONObject(); + body.put("range", range); + JSONArray valuesArray = new JSONArray(); + if (values != null) { + for (List row : values) { + JSONArray rowArray = new JSONArray(); + if (row != null) { + for (Object cell : row) { + rowArray.add(cell != null ? cell : ""); + } + } + valuesArray.add(rowArray); + } + } + body.put("values", valuesArray); + String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets/" + sheetIdx + "/cells"; + return postOpenApi(path, accessToken, body.toJSONString()); + } + + @Override + public JSONObject createSheet(String accessToken, String fileToken, String sheetName) { + JSONObject body = new JSONObject(); + body.put("name", sheetName); + String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets"; + return postOpenApi(path, accessToken, body.toJSONString()); + } + + @Override + public JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List> updates) { + JSONObject body = new JSONObject(); + JSONArray updatesArray = new JSONArray(); + if (updates != null) { + for (Map update : updates) { + JSONObject u = new JSONObject(); + u.put("range", update.get("range")); + @SuppressWarnings("unchecked") + List> values = (List>) update.get("values"); + JSONArray valuesArray = new JSONArray(); + if (values != null) { + for (List row : values) { + JSONArray rowArray = new JSONArray(); + if (row != null) { + for (Object cell : row) { + rowArray.add(cell != null ? cell : ""); + } + } + valuesArray.add(rowArray); + } + } + u.put("values", valuesArray); + updatesArray.add(u); + } + } + body.put("updates", updatesArray); + String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets/" + sheetIdx + "/cells/batch"; + return postOpenApi(path, accessToken, body.toJSONString()); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WPS365ApiServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WPS365ApiServiceImpl.java deleted file mode 100644 index 682cfc0..0000000 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WPS365ApiServiceImpl.java +++ /dev/null @@ -1,473 +0,0 @@ -package com.ruoyi.jarvis.service.impl; - -import com.alibaba.fastjson2.JSONArray; -import com.alibaba.fastjson2.JSONObject; -import com.ruoyi.jarvis.config.WPS365Config; -import com.ruoyi.jarvis.service.IWPS365ApiService; -import com.ruoyi.jarvis.util.WPS365ApiUtil; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Map; - -/** - * WPS365 API服务实现类 - * - * @author system - */ -@Service -public class WPS365ApiServiceImpl implements IWPS365ApiService { - - private static final Logger log = LoggerFactory.getLogger(WPS365ApiServiceImpl.class); - - @Autowired - private WPS365Config wps365Config; - - @Override - public JSONObject getUserInfo(String accessToken) { - // 官方文档(open.wps.cn 用户授权流程)仅写“通过 access_token 获取用户信息”,未给出具体路径; - // /api/v1/user/info 返回 404,以下按常见惯例尝试,若均不可用则返回 null,由 Controller 降级返回 - String[] urlsToTry = { - "https://openapi.wps.cn/v7/user", - "https://openapi.wps.cn/v7/userinfo", - wps365Config.getApiBaseUrl() + "/userinfo", - wps365Config.getApiBaseUrl() + "/user/info" - }; - for (String url : urlsToTry) { - try { - log.debug("尝试用户信息API: {}", url); - JSONObject result = WPS365ApiUtil.httpRequest("GET", url, accessToken, null); - if (result != null) { - log.debug("用户信息API成功 - url: {}", url); - return result; - } - } catch (Exception e) { - log.debug("用户信息API失败 - url: {}, error: {}", url, e.getMessage()); - } - } - log.warn("所有用户信息接口均不可用,请以 open.wps.cn 文档为准确认正确路径"); - return null; - } - - @Override - public JSONObject getFileList(String accessToken, Map params) { - // 官方文档:云文档文件在「驱动盘」下,路径为 GET https://openapi.wps.cn/v7/drives/{drive_id}/files - // /api/v1/yundoc/files 会 404,需先取 drive_id 再请求 v7/drives/{drive_id}/files - String baseV7 = "https://openapi.wps.cn/v7"; - int page = params != null && params.get("page") != null ? ((Number) params.get("page")).intValue() : 1; - int pageSize = params != null && params.get("page_size") != null ? ((Number) params.get("page_size")).intValue() : 20; - if (params != null && params.get("pageSize") != null) { - pageSize = ((Number) params.get("pageSize")).intValue(); - } - - String driveId = null; - // 1) 尝试获取驱动盘列表:GET /v7/drives - try { - JSONObject drivesRes = WPS365ApiUtil.httpRequest("GET", baseV7 + "/drives", accessToken, null); - if (drivesRes != null) { - JSONArray items = drivesRes.getJSONArray("items"); - if (items == null) { - items = drivesRes.getJSONArray("drives"); - } - if (items == null && drivesRes.get("data") != null) { - Object data = drivesRes.get("data"); - if (data instanceof JSONArray) { - items = (JSONArray) data; - } else if (data instanceof JSONObject) { - JSONObject dataObj = (JSONObject) data; - items = dataObj.getJSONArray("items"); - if (items == null) { - items = dataObj.getJSONArray("drives"); - } - } - } - if (items != null && !items.isEmpty()) { - for (int i = 0; i < items.size(); i++) { - JSONObject item = items.getJSONObject(i); - String id = item.getString("id"); - if (id != null && !id.isEmpty()) { - // 优先个人盘 allotee_type=user(我的云文档) - if ("user".equalsIgnoreCase(item.getString("allotee_type"))) { - driveId = id; - break; - } - if (driveId == null) { - driveId = id; - } - } - } - } - } - } catch (Exception e) { - log.debug("获取驱动盘列表失败,尝试其他方式: {}", e.getMessage()); - } - - // 2) 若没有 drive_id,尝试「当前用户默认盘」常见路径 - if (driveId == null) { - try { - JSONObject meRes = WPS365ApiUtil.httpRequest("GET", baseV7 + "/drives/me", accessToken, null); - if (meRes != null && meRes.getString("id") != null) { - driveId = meRes.getString("id"); - } - } catch (Exception e) { - log.debug("v7/drives/me 失败: {}", e.getMessage()); - } - } - - if (driveId == null) { - throw new RuntimeException("获取文件列表失败:无法获取驱动盘ID(drive_id)。请确认已授权且 WPS 开放平台云文档接口可用,参见 open.wps.cn 云文档业务域文档。"); - } - - // 3) 获取该盘下的文件列表 - String filesUrl = baseV7 + "/drives/" + driveId + "/files?page=" + page + "&page_size=" + pageSize; - try { - log.debug("调用文件列表API: {}", filesUrl); - JSONObject result = WPS365ApiUtil.httpRequest("GET", filesUrl, accessToken, null); - // 前端期望 data.files;v7 可能返回 items,统一成 files - if (result != null && result.get("files") == null && result.getJSONArray("items") != null) { - result.put("files", result.getJSONArray("items")); - } - return result != null ? result : new JSONObject(); - } catch (Exception e) { - log.error("获取文件列表失败 - url: {}", filesUrl, e); - throw new RuntimeException("获取文件列表失败: " + e.getMessage(), e); - } - } - - @Override - public JSONObject getFileInfo(String accessToken, String fileToken) { - try { - // WPS365文件信息API路径:/yundoc/files/{fileToken} - String url = wps365Config.getApiBaseUrl() + "/yundoc/files/" + fileToken; - log.debug("调用文件信息API: {}", url); - return WPS365ApiUtil.httpRequest("GET", url, accessToken, null); - } catch (Exception e) { - log.error("获取文件信息失败 - fileToken: {}, url: {}", fileToken, - wps365Config.getApiBaseUrl() + "/yundoc/files/" + fileToken, e); - throw new RuntimeException("获取文件信息失败: " + e.getMessage(), e); - } - } - - @Override - public JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List> values) { - try { - // WPS365 KSheet API: /api/v1/openapi/ksheet/:file_token/sheets/:sheet_idx/cells - String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets/" + sheetIdx + "/cells"; - - // 构建请求体 - JSONObject requestBody = new JSONObject(); - requestBody.put("range", range); - - // 将values转换为JSONArray - JSONArray valuesArray = new JSONArray(); - if (values != null) { - for (List row : values) { - JSONArray rowArray = new JSONArray(); - if (row != null) { - for (Object cell : row) { - rowArray.add(cell != null ? cell : ""); - } - } - valuesArray.add(rowArray); - } - } - requestBody.put("values", valuesArray); - - String bodyStr = requestBody.toJSONString(); - log.debug("更新单元格数据 - url: {}, range: {}, values: {}", url, range, bodyStr); - - return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr); - } catch (Exception e) { - log.error("更新单元格数据失败 - fileToken: {}, sheetIdx: {}, range: {}", fileToken, sheetIdx, range, e); - throw new RuntimeException("更新单元格数据失败: " + e.getMessage(), e); - } - } - - @Override - public JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range) { - try { - // WPS365 KSheet API: GET /api/v1/openapi/ksheet/:file_token/sheets/:sheet_idx/cells - String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets/" + sheetIdx + "/cells"; - if (range != null && !range.trim().isEmpty()) { - url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8"); - } - - return WPS365ApiUtil.httpRequest("GET", url, accessToken, null); - } catch (Exception e) { - log.error("读取单元格数据失败 - fileToken: {}, sheetIdx: {}, range: {}", fileToken, sheetIdx, range, e); - throw new RuntimeException("读取单元格数据失败: " + e.getMessage(), e); - } - } - - @Override - public JSONObject getSheetList(String accessToken, String fileToken) { - try { - // WPS365 KSheet API: GET /api/v1/openapi/ksheet/:file_token/sheets - String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets"; - return WPS365ApiUtil.httpRequest("GET", url, accessToken, null); - } catch (Exception e) { - log.error("获取工作表列表失败 - fileToken: {}", fileToken, e); - throw new RuntimeException("获取工作表列表失败: " + e.getMessage(), e); - } - } - - @Override - public JSONObject createSheet(String accessToken, String fileToken, String sheetName) { - try { - // WPS365 KSheet API: POST /api/v1/openapi/ksheet/:file_token/sheets - String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets"; - - JSONObject requestBody = new JSONObject(); - requestBody.put("name", sheetName); - - String bodyStr = requestBody.toJSONString(); - return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr); - } catch (Exception e) { - log.error("创建数据表失败 - fileToken: {}, sheetName: {}", fileToken, sheetName, e); - throw new RuntimeException("创建数据表失败: " + e.getMessage(), e); - } - } - - @Override - public JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List> updates) { - try { - // WPS365 KSheet API: POST /api/v1/openapi/ksheet/:file_token/sheets/:sheet_idx/cells/batch - String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets/" + sheetIdx + "/cells/batch"; - - JSONObject requestBody = new JSONObject(); - JSONArray updatesArray = new JSONArray(); - - if (updates != null) { - for (Map update : updates) { - JSONObject updateObj = new JSONObject(); - updateObj.put("range", update.get("range")); - - // 将values转换为JSONArray - @SuppressWarnings("unchecked") - List> values = (List>) update.get("values"); - JSONArray valuesArray = new JSONArray(); - if (values != null) { - for (List row : values) { - JSONArray rowArray = new JSONArray(); - if (row != null) { - for (Object cell : row) { - rowArray.add(cell != null ? cell : ""); - } - } - valuesArray.add(rowArray); - } - } - updateObj.put("values", valuesArray); - updatesArray.add(updateObj); - } - } - - requestBody.put("updates", updatesArray); - String bodyStr = requestBody.toJSONString(); - - return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr); - } catch (Exception e) { - log.error("批量更新单元格数据失败 - fileToken: {}, sheetIdx: {}", fileToken, sheetIdx, e); - throw new RuntimeException("批量更新单元格数据失败: " + e.getMessage(), e); - } - } - - @Override - public JSONObject readAirSheetCells(String accessToken, String fileId, String worksheetId, String range) { - // 智能表格(AirSheet):openapi.wps.cn 下 airsheet 接口目前均返回 404,先试 AirSheet 再回退到 KSheet(同一 fileId 作为 file_token 试读) - String baseUrl = wps365Config.getApiBaseUrl(); - int sheetIdx = parseSheetIndex(worksheetId, fileId); - - // 方案1:AirSheet 路径 GET /openapi/airsheet/{fileId}/sheets/{sheetIdx}/cells - try { - String url = baseUrl + "/openapi/airsheet/" + fileId + "/sheets/" + sheetIdx + "/cells"; - if (range != null && !range.trim().isEmpty()) { - url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8"); - } - log.debug("读取AirSheet - url: {}, fileId: {}, sheetIdx: {}", url, fileId, sheetIdx); - return WPS365ApiUtil.httpRequest("GET", url, accessToken, null); - } catch (Exception e) { - log.warn("AirSheet 接口 404 或失败,尝试 KSheet 回退 - {}", e.getMessage()); - } - - // 方案2:用同一 ID 走 KSheet(在线表格)接口——若文档在「文件列表」里以 KSheet 形式存在则可用 - try { - String url = baseUrl + "/openapi/ksheet/" + fileId + "/sheets/" + sheetIdx + "/cells"; - if (range != null && !range.trim().isEmpty()) { - url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8"); - } - log.debug("回退到 KSheet 读取 - url: {}, fileId 作 file_token: {}", url, fileId); - return WPS365ApiUtil.httpRequest("GET", url, accessToken, null); - } catch (Exception e2) { - log.warn("KSheet 回退也失败 - {}", e2.getMessage()); - } - - // 方案3:兼容旧版 v7 airsheet(若官方恢复) - try { - String url = "https://openapi.wps.cn/v7/airsheet/" + fileId + "/sheets/" + sheetIdx + "/cells"; - if (range != null && !range.trim().isEmpty()) { - url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8"); - } - return WPS365ApiUtil.httpRequest("GET", url, accessToken, null); - } catch (Exception e3) { - throw new RuntimeException( - "读取智能表格失败:AirSheet 与 KSheet 接口均不可用(404)。请确认:1) 使用「文件列表」接口返回的 file_token 再试;2) 若文档在金山文档(kdocs.cn)创建,需用金山文档开放平台(developer.kdocs.cn)的 KSheet API。原始错误: " + e3.getMessage(), - e3); - } - } - - /** 解析工作表索引:0、空或与 fileId 相同时为 0,否则按数字解析 */ - private int parseSheetIndex(String worksheetId, String fileId) { - if (worksheetId == null || worksheetId.trim().isEmpty() || "0".equals(worksheetId) || (fileId != null && fileId.equals(worksheetId))) { - return 0; - } - try { - return Integer.parseInt(worksheetId.trim()); - } catch (NumberFormatException e) { - return 0; - } - } - - @Override - public JSONObject updateAirSheetCells(String accessToken, String fileId, String worksheetId, String range, List> values) { - String baseUrl = wps365Config.getApiBaseUrl(); - int sheetIdx = parseSheetIndex(worksheetId, fileId); - - JSONObject requestBody = new JSONObject(); - if (range != null && !range.trim().isEmpty()) { - requestBody.put("range", range); - } - JSONArray valuesArray = new JSONArray(); - if (values != null) { - for (List row : values) { - JSONArray rowArray = new JSONArray(); - if (row != null) { - for (Object cell : row) { - rowArray.add(cell != null ? cell : ""); - } - } - valuesArray.add(rowArray); - } - } - requestBody.put("values", valuesArray); - String bodyStr = requestBody.toJSONString(); - - // 方案1:AirSheet 路径 POST /openapi/airsheet/{fileId}/sheets/{sheetIdx}/cells - try { - String url = baseUrl + "/openapi/airsheet/" + fileId + "/sheets/" + sheetIdx + "/cells"; - log.debug("更新AirSheet - url: {}, fileId: {}, sheetIdx: {}", url, fileId, sheetIdx); - return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr); - } catch (Exception e) { - log.warn("AirSheet 写入失败,尝试 KSheet 回退 - {}", e.getMessage()); - } - - // 方案2:用同一 ID 走 KSheet 写入接口 - try { - String url = baseUrl + "/openapi/ksheet/" + fileId + "/sheets/" + sheetIdx + "/cells"; - log.debug("回退到 KSheet 写入 - url: {}", url); - return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr); - } catch (Exception e2) { - log.warn("KSheet 回退写入也失败 - {}", e2.getMessage()); - } - - // 方案3:兼容 v7 PUT /airsheet/{file_id}/worksheets - try { - String url = "https://openapi.wps.cn/v7/airsheet/" + fileId + "/worksheets"; - return WPS365ApiUtil.httpRequest("PUT", url, accessToken, bodyStr); - } catch (Exception e3) { - throw new RuntimeException( - "更新智能表格失败:AirSheet 与 KSheet 接口均不可用。请使用「文件列表」返回的 file_token 调用 KSheet 写入接口,或确认文档类型。原始错误: " + e3.getMessage(), - e3); - } - } - - /** - * 解析A1:B5格式的range,转换为行列参数 - * 返回数组:[row_from, row_to, col_from, col_to] - * 注意:WPS365的行列索引可能从0开始或从1开始,这里假设从1开始(Excel标准) - * - * @param range 单元格范围,如 "A1:B5" - * @return 行列参数数组,如果解析失败返回null - */ - private int[] parseRangeToRowCol(String range) { - if (range == null || range.trim().isEmpty()) { - return null; - } - - try { - // 解析A1:B5格式 - String[] parts = range.split(":"); - if (parts.length != 2) { - return null; - } - - String startCell = parts[0].trim(); - String endCell = parts[1].trim(); - - // 解析起始单元格,如 "A1" -> row=1, col=1 - int[] start = parseCellAddress(startCell); - int[] end = parseCellAddress(endCell); - - if (start == null || end == null) { - return null; - } - - // 返回 [row_from, row_to, col_from, col_to] - // 注意:WPS365可能从0开始索引,这里先使用从1开始的索引(Excel标准) - // 如果API要求从0开始,需要减1 - return new int[]{start[0], end[0], start[1], end[1]}; - } catch (Exception e) { - log.warn("解析range失败: {}", range, e); - return null; - } - } - - /** - * 解析单元格地址,如 "A1" -> [row=1, col=1] - * - * @param cellAddress 单元格地址,如 "A1", "B5" - * @return [row, col] 数组,如果解析失败返回null - */ - private int[] parseCellAddress(String cellAddress) { - if (cellAddress == null || cellAddress.trim().isEmpty()) { - return null; - } - - try { - // 分离字母部分(列)和数字部分(行) - // 例如 "A1" -> col="A", row="1" - String colStr = ""; - String rowStr = ""; - - for (char c : cellAddress.toCharArray()) { - if (Character.isLetter(c)) { - colStr += c; - } else if (Character.isDigit(c)) { - rowStr += c; - } - } - - if (colStr.isEmpty() || rowStr.isEmpty()) { - return null; - } - - // 转换列字母为数字,A=1, B=2, ..., Z=26, AA=27, ... - int col = 0; - for (char c : colStr.toUpperCase().toCharArray()) { - col = col * 26 + (c - 'A' + 1); - } - - // 转换行号为整数 - int row = Integer.parseInt(rowStr); - - return new int[]{row, col}; - } catch (Exception e) { - log.warn("解析单元格地址失败: {}", cellAddress, e); - return null; - } - } -} - diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WPS365OAuthServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WPS365OAuthServiceImpl.java deleted file mode 100644 index bc07051..0000000 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WPS365OAuthServiceImpl.java +++ /dev/null @@ -1,365 +0,0 @@ -package com.ruoyi.jarvis.service.impl; - -import com.alibaba.fastjson2.JSONObject; -import com.ruoyi.jarvis.config.WPS365Config; -import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo; -import com.ruoyi.jarvis.service.IWPS365OAuthService; -import com.ruoyi.jarvis.service.IWPS365ApiService; -import com.ruoyi.jarvis.util.WPS365ApiUtil; -import com.ruoyi.common.core.redis.RedisCache; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.UUID; -import java.util.concurrent.TimeUnit; - -/** - * WPS365 OAuth授权服务实现类 - * - * @author system - */ -@Service -public class WPS365OAuthServiceImpl implements IWPS365OAuthService { - - private static final Logger log = LoggerFactory.getLogger(WPS365OAuthServiceImpl.class); - - /** Redis key前缀,用于存储用户token */ - private static final String TOKEN_KEY_PREFIX = "wps365:token:"; - - /** Token过期时间(秒),默认30天 */ - private static final long TOKEN_EXPIRE_TIME = 30 * 24 * 60 * 60; - - @Autowired - private WPS365Config wps365Config; - - @Autowired - private RedisCache redisCache; - - @Autowired - private IWPS365ApiService wps365ApiService; - - @Override - public String getAuthUrl(String state) { - if (wps365Config == null) { - throw new RuntimeException("WPS365配置未加载,请检查WPS365Config是否正确注入"); - } - - String appId = wps365Config.getAppId(); - String redirectUri = wps365Config.getRedirectUri(); - String oauthUrl = wps365Config.getOauthUrl(); - - log.debug("获取授权URL - appId: {}, redirectUri: {}, oauthUrl: {}", appId, redirectUri, oauthUrl); - - // 验证配置参数 - if (appId == null || appId.trim().isEmpty()) { - throw new RuntimeException("WPS365应用ID未配置,请检查application.yml中的wps365.app-id配置"); - } - if (redirectUri == null || redirectUri.trim().isEmpty()) { - throw new RuntimeException("WPS365回调地址未配置,请检查application.yml中的wps365.redirect-uri配置"); - } - - // 构建授权URL - StringBuilder authUrl = new StringBuilder(); - authUrl.append(oauthUrl); - - // WPS365可能使用 app_id 而不是 client_id,先尝试 client_id(标准OAuth2参数) - // 如果失败,可能需要改为 app_id - authUrl.append("?client_id=").append(appId); - log.debug("授权URL参数 - client_id: {}", appId); - - // 重要:redirect_uri必须与WPS365开放平台配置的回调地址完全一致 - // 包括协议(https)、域名、路径,不能有多余的斜杠 - String finalRedirectUri = redirectUri.trim(); - // 确保URL末尾没有斜杠(除非是根路径) - if (finalRedirectUri.endsWith("/") && !finalRedirectUri.equals("https://")) { - finalRedirectUri = finalRedirectUri.substring(0, finalRedirectUri.length() - 1); - } - - // 验证redirect_uri不为空 - if (finalRedirectUri == null || finalRedirectUri.isEmpty()) { - throw new RuntimeException("redirect_uri不能为空,请检查application.yml中的wps365.redirect-uri配置"); - } - - try { - String encodedRedirectUri = java.net.URLEncoder.encode(finalRedirectUri, "UTF-8"); - authUrl.append("&redirect_uri=").append(encodedRedirectUri); - log.info("使用回调地址: {} (编码后: {})", finalRedirectUri, encodedRedirectUri); - } catch (java.io.UnsupportedEncodingException e) { - log.error("URL编码失败", e); - authUrl.append("&redirect_uri=").append(finalRedirectUri); - } - - // response_type参数(必需) - authUrl.append("&response_type=code"); - log.debug("授权URL参数 - response_type: code"); - - // scope参数(必需,根据WPS365官方文档) - // 优先使用配置文件中指定的scope,如果没有配置则使用默认值 - // 重要:WPS365官方文档明确要求使用英文逗号分隔,且权限名称必须与后台注册的完全一致 - // 根据官方文档:https://open.wps.cn/documents/app-integration-dev/wps365/server/ - // 权限名称格式为:kso.xxx.read 或 kso.xxx.readwrite(不是 file.read) - String scope = wps365Config.getScope(); - if (scope == null || scope.trim().isEmpty()) { - // 默认scope,根据WPS365官方文档: - // 1. 必须使用英文逗号分隔(不是空格) - // 2. 权限名称必须以 kso. 开头,格式如:kso.file.read, kso.file.readwrite - // 3. 常见权限名称(根据官方文档): - // - kso.file.read (文件读取) - // - kso.file.readwrite (文件读写) - // - kso.doclib.readwrite (文档库读写) - // - kso.wiki.readwrite (知识库读写) - // - 对于在线表格(KSheet),可能需要 kso.file.readwrite - // - 对于智能表格(AirSheet)读写,需要 kso.airsheet.readwrite - // - // 如果报错invalid_scope,请: - // 1. 登录WPS365开放平台:https://open.wps.cn/ - // 2. 进入"开发配置" > "权限管理" - // 3. 查看已申请权限的准确名称(必须以 kso. 开头) - // 4. 在application.yml中配置scope参数,使用逗号分隔 - scope = "kso.file.readwrite,kso.airsheet.readwrite"; // 文件读写 + 智能表格读写(后端写入智能表格必须含 kso.airsheet.readwrite) - } - scope = scope.trim(); - - // URL编码scope参数 - try { - String encodedScope = java.net.URLEncoder.encode(scope, "UTF-8"); - authUrl.append("&scope=").append(encodedScope); - log.debug("授权URL参数 - scope: {} (编码后: {})", scope, encodedScope); - } catch (java.io.UnsupportedEncodingException e) { - log.error("Scope URL编码失败", e); - authUrl.append("&scope=").append(scope); - } - - // state参数(推荐,用于防止CSRF攻击) - if (state == null || state.trim().isEmpty()) { - state = UUID.randomUUID().toString(); - } - authUrl.append("&state=").append(state); - log.debug("授权URL参数 - state: {}", state); - - // prompt参数(可选,用于控制授权页面显示) - // prompt=consent: 强制显示授权确认页面,即使用户已授权过 - // prompt=login: 强制显示登录页面 - // 如果不添加此参数,已登录且已授权的用户会直接跳过授权页面 - // 注意:WPS365可能不支持此参数,如果不支持会被忽略 - authUrl.append("&prompt=consent"); - log.debug("授权URL参数 - prompt: consent (强制显示授权确认页面)"); - - String result = authUrl.toString(); - log.info("生成授权URL: {}", result); - log.warn("⚠️ 请确保WPS365开放平台配置的回调地址与以下地址完全一致(包括协议、域名、路径):"); - log.warn("⚠️ 回调地址: {}", finalRedirectUri); - log.info("📋 授权请求参数清单:"); - log.info(" - client_id: {}", appId); - log.info(" - redirect_uri: {}", finalRedirectUri); - log.info(" - response_type: code"); - log.info(" - scope: {}", scope); - log.info(" - state: {}", state); - log.info(" - prompt: consent (强制显示授权确认页面)"); - log.info("💡 说明:已添加 prompt=consent 参数,强制显示授权确认页面"); - log.info(" 如果用户已登录且已授权过,WPS365可能会跳过授权页面直接返回code"); - log.info(" 这是正常的OAuth2行为,不是安全问题"); - log.info("如果仍然报错,请检查:"); - log.info(" 1. WPS365平台配置的回调地址是否与上述redirect_uri完全一致"); - log.info(" 2. 参数名是否正确(WPS365可能使用app_id而不是client_id)"); - log.info(" 3. scope权限是否已在WPS365平台申请"); - return result; - } - - @Override - public WPS365TokenInfo getAccessTokenByCode(String code) { - try { - JSONObject result = WPS365ApiUtil.getAccessToken( - wps365Config.getAppId(), - wps365Config.getAppKey(), - code, - wps365Config.getRedirectUri(), - wps365Config.getTokenUrl() - ); - - // 解析响应并创建TokenInfo对象 - WPS365TokenInfo tokenInfo = new WPS365TokenInfo(); - tokenInfo.setAccessToken(result.getString("access_token")); - tokenInfo.setRefreshToken(result.getString("refresh_token")); - tokenInfo.setTokenType(result.getString("token_type")); - tokenInfo.setExpiresIn(result.getInteger("expires_in")); - tokenInfo.setScope(result.getString("scope")); - - // WPS365的token响应中可能不包含user_id,需要调用用户信息API获取 - String userId = result.getString("user_id"); - if (userId == null || userId.trim().isEmpty()) { - // 尝试通过用户信息API获取用户ID - try { - JSONObject userInfo = wps365ApiService.getUserInfo(tokenInfo.getAccessToken()); - if (userInfo != null) { - // 尝试多种可能的用户ID字段名 - userId = userInfo.getString("id"); - if (userId == null || userId.trim().isEmpty()) { - userId = userInfo.getString("user_id"); - } - if (userId == null || userId.trim().isEmpty()) { - userId = userInfo.getString("open_id"); - } - if (userId == null || userId.trim().isEmpty()) { - userId = userInfo.getString("uid"); - } - // 如果还是获取不到,使用access_token的前16位作为标识(临时方案) - if (userId == null || userId.trim().isEmpty()) { - String accessToken = tokenInfo.getAccessToken(); - if (accessToken != null && accessToken.length() > 16) { - userId = "wps365_" + accessToken.substring(0, 16); - log.warn("无法从用户信息API获取用户ID,使用access_token前16位作为标识: {}", userId); - } else { - userId = "wps365_default"; - log.warn("无法获取用户ID,使用默认值: {}", userId); - } - } else { - log.info("通过用户信息API获取到用户ID: {}", userId); - } - } else { - userId = "wps365_default"; - log.warn("用户信息API返回为空,使用默认用户ID: {}", userId); - } - } catch (Exception e) { - log.warn("调用用户信息API失败,使用默认用户ID: {}", e.getMessage()); - // 使用access_token的前16位作为标识(临时方案) - String accessToken = tokenInfo.getAccessToken(); - if (accessToken != null && accessToken.length() > 16) { - userId = "wps365_" + accessToken.substring(0, 16); - } else { - userId = "wps365_default"; - } - } - } - - tokenInfo.setUserId(userId); - log.info("成功获取访问令牌 - userId: {}", userId); - - return tokenInfo; - } catch (Exception e) { - log.error("通过授权码获取访问令牌失败", e); - throw new RuntimeException("获取访问令牌失败: " + e.getMessage(), e); - } - } - - @Override - public WPS365TokenInfo refreshAccessToken(String refreshToken) { - try { - JSONObject result = WPS365ApiUtil.refreshAccessToken( - wps365Config.getAppId(), - wps365Config.getAppKey(), - refreshToken, - wps365Config.getRefreshTokenUrl() - ); - - // 解析响应并创建TokenInfo对象 - WPS365TokenInfo tokenInfo = new WPS365TokenInfo(); - tokenInfo.setAccessToken(result.getString("access_token")); - tokenInfo.setRefreshToken(result.getString("refresh_token")); - tokenInfo.setTokenType(result.getString("token_type")); - tokenInfo.setExpiresIn(result.getInteger("expires_in")); - tokenInfo.setScope(result.getString("scope")); - tokenInfo.setUserId(result.getString("user_id")); - - log.info("成功刷新访问令牌 - userId: {}", tokenInfo.getUserId()); - - return tokenInfo; - } catch (Exception e) { - log.error("刷新访问令牌失败", e); - throw new RuntimeException("刷新访问令牌失败: " + e.getMessage(), e); - } - } - - @Override - public WPS365TokenInfo getCurrentToken() { - // 尝试查找所有WPS365 token(通常只有一个) - // 使用Redis的keys命令查找所有匹配的token key - try { - String pattern = TOKEN_KEY_PREFIX + "*"; - // 注意:keys命令在生产环境可能性能较差,但这里token数量通常很少 - java.util.Collection keys = redisCache.keys(pattern); - if (keys != null && !keys.isEmpty()) { - // 返回第一个找到的有效token - for (String key : keys) { - WPS365TokenInfo tokenInfo = redisCache.getCacheObject(key); - if (tokenInfo != null && isTokenValid(tokenInfo)) { - log.debug("找到有效的WPS365 token: {}", key); - return tokenInfo; - } - } - // 如果没有有效的token,返回第一个(即使过期) - for (String key : keys) { - WPS365TokenInfo tokenInfo = redisCache.getCacheObject(key); - if (tokenInfo != null) { - log.debug("找到WPS365 token(可能已过期): {}", key); - return tokenInfo; - } - } - } - } catch (Exception e) { - log.warn("查找WPS365 token失败", e); - } - return null; - } - - @Override - public void saveToken(String userId, WPS365TokenInfo tokenInfo) { - if (userId == null || userId.trim().isEmpty()) { - throw new IllegalArgumentException("用户ID不能为空"); - } - if (tokenInfo == null || tokenInfo.getAccessToken() == null) { - throw new IllegalArgumentException("Token信息不能为空"); - } - - String key = TOKEN_KEY_PREFIX + userId; - redisCache.setCacheObject(key, tokenInfo, (int) TOKEN_EXPIRE_TIME, TimeUnit.SECONDS); - log.info("保存用户Token - userId: {}", userId); - } - - @Override - public void clearToken(String userId) { - if (userId == null || userId.trim().isEmpty()) { - return; - } - - String key = TOKEN_KEY_PREFIX + userId; - redisCache.deleteObject(key); - log.info("清除用户Token - userId: {}", userId); - } - - @Override - public boolean isTokenValid(WPS365TokenInfo tokenInfo) { - if (tokenInfo == null) { - return false; - } - - // 检查是否过期 - if (tokenInfo.isExpired()) { - log.debug("Token已过期"); - return false; - } - - // 检查必要字段 - if (tokenInfo.getAccessToken() == null || tokenInfo.getAccessToken().trim().isEmpty()) { - log.debug("Token缺少accessToken"); - return false; - } - - return true; - } - - /** - * 根据用户ID获取Token(从Redis) - */ - public WPS365TokenInfo getTokenByUserId(String userId) { - if (userId == null || userId.trim().isEmpty()) { - return null; - } - - String key = TOKEN_KEY_PREFIX + userId; - return redisCache.getCacheObject(key); - } -} - diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/KdocsOpenApiClient.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/KdocsOpenApiClient.java new file mode 100644 index 0000000..73b9a04 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/KdocsOpenApiClient.java @@ -0,0 +1,99 @@ +package com.ruoyi.jarvis.util; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.common.utils.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** + * 调用金山文档开放平台 HTTP 接口(含 OAuth 换票与 Open API) + */ +public final class KdocsOpenApiClient { + + private static final Logger log = LoggerFactory.getLogger(KdocsOpenApiClient.class); + + static { + System.setProperty("java.net.useSystemProxies", "false"); + } + + private KdocsOpenApiClient() { + } + + /** + * 请求 JSON 接口,返回根对象(含 code、data) + * + * @param jsonContentTypeForGet 部分 GET(如 oauth2/access_token)要求 Content-Type: application/json + */ + public static JSONObject exchange(String method, String fullUrl, String jsonBody, boolean jsonContentTypeForGet) { + try { + URL u = new URL(fullUrl); + HttpURLConnection conn = (HttpURLConnection) u.openConnection(java.net.Proxy.NO_PROXY); + conn.setRequestMethod(method); + conn.setConnectTimeout(15000); + conn.setReadTimeout(60000); + conn.setRequestProperty("Accept", "application/json"); + if ("GET".equals(method) && jsonContentTypeForGet) { + conn.setRequestProperty("Content-Type", "application/json"); + } + if ("POST".equals(method) || "PUT".equals(method)) { + conn.setRequestProperty("Content-Type", "application/json"); + conn.setDoOutput(true); + if (jsonBody != null && !jsonBody.isEmpty()) { + try (OutputStreamWriter w = new OutputStreamWriter(conn.getOutputStream(), StandardCharsets.UTF_8)) { + w.write(jsonBody); + w.flush(); + } + } + } + + int status = conn.getResponseCode(); + StringBuilder sb = new StringBuilder(); + InputStream stream = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + if (stream != null) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + } + String text = sb.toString(); + log.debug("Kdocs HTTP {} {} -> {} body: {}", method, fullUrl, status, text.length() > 500 ? text.substring(0, 500) + "..." : text); + if (status < 200 || status >= 300) { + throw new RuntimeException("HTTP " + status + ": " + text); + } + if (StringUtils.isBlank(text)) { + return new JSONObject(); + } + return JSON.parseObject(text); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Kdocs 请求失败: " + e.getMessage(), e); + } + } + + /** + * 业务 Open API:code 必须为 0,返回 data + */ + public static JSONObject requireBusinessData(JSONObject root) { + if (root == null) { + throw new RuntimeException("Kdocs 响应为空"); + } + Integer c = root.getInteger("code"); + if (c == null || c != 0) { + throw new RuntimeException("Kdocs 业务错误: " + root.toJSONString()); + } + JSONObject data = root.getJSONObject("data"); + return data != null ? data : new JSONObject(); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/WPS365ApiUtil.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/WPS365ApiUtil.java deleted file mode 100644 index 8695446..0000000 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/WPS365ApiUtil.java +++ /dev/null @@ -1,284 +0,0 @@ -package com.ruoyi.jarvis.util; - -import com.alibaba.fastjson2.JSON; -import com.alibaba.fastjson2.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; - -/** - * WPS365 API工具类 - * - * @author system - */ -public class WPS365ApiUtil { - - private static final Logger log = LoggerFactory.getLogger(WPS365ApiUtil.class); - - // 静态初始化块:禁用系统代理 - static { - System.setProperty("java.net.useSystemProxies", "false"); - System.clearProperty("http.proxyHost"); - System.clearProperty("http.proxyPort"); - System.clearProperty("https.proxyHost"); - System.clearProperty("https.proxyPort"); - log.info("已禁用系统代理设置,WPS365 API将直接连接"); - } - - /** - * 获取访问令牌 - * - * @param appId 应用ID - * @param appKey 应用密钥 - * @param code 授权码 - * @param redirectUri 回调地址 - * @param tokenUrl Token地址 - * @return 包含access_token和refresh_token的JSON对象 - */ - public static JSONObject getAccessToken(String appId, String appKey, String code, String redirectUri, String tokenUrl) { - try { - // 构建请求参数 - StringBuilder params = new StringBuilder(); - params.append("grant_type=authorization_code"); - params.append("&client_id=").append(appId); - params.append("&client_secret=").append(appKey); - params.append("&code=").append(code); - params.append("&redirect_uri=").append(java.net.URLEncoder.encode(redirectUri, "UTF-8")); - - // 使用HttpURLConnection,不使用代理 - URL url = new URL(tokenUrl); - java.net.Proxy proxy = java.net.Proxy.NO_PROXY; - HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy); - conn.setRequestMethod("POST"); - conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); - conn.setRequestProperty("Accept", "application/json"); - conn.setDoOutput(true); - conn.setDoInput(true); - conn.setConnectTimeout(10000); - conn.setReadTimeout(30000); - - // 写入请求参数 - try (OutputStream os = conn.getOutputStream(); - OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8)) { - osw.write(params.toString()); - osw.flush(); - } - - // 读取响应 - int statusCode = conn.getResponseCode(); - BufferedReader reader; - if (statusCode >= 200 && statusCode < 300) { - reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)); - } else { - reader = new BufferedReader(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8)); - } - - StringBuilder response = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - response.append(line); - } - reader.close(); - - String responseStr = response.toString(); - log.info("获取访问令牌响应: statusCode={}, response={}", statusCode, responseStr); - - if (statusCode < 200 || statusCode >= 300) { - throw new RuntimeException("获取访问令牌失败: HTTP " + statusCode + ", response=" + responseStr); - } - - return JSON.parseObject(responseStr); - } catch (Exception e) { - log.error("获取访问令牌失败", e); - throw new RuntimeException("获取访问令牌失败: " + e.getMessage(), e); - } - } - - /** - * 刷新访问令牌 - * - * @param appId 应用ID - * @param appKey 应用密钥 - * @param refreshToken 刷新令牌 - * @param refreshTokenUrl 刷新令牌地址 - * @return 包含新的access_token和refresh_token的JSON对象 - */ - public static JSONObject refreshAccessToken(String appId, String appKey, String refreshToken, String refreshTokenUrl) { - try { - // 构建请求参数 - StringBuilder params = new StringBuilder(); - params.append("grant_type=refresh_token"); - params.append("&client_id=").append(appId); - params.append("&client_secret=").append(appKey); - params.append("&refresh_token=").append(refreshToken); - - // 使用HttpURLConnection,不使用代理 - URL url = new URL(refreshTokenUrl); - java.net.Proxy proxy = java.net.Proxy.NO_PROXY; - HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy); - conn.setRequestMethod("POST"); - conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); - conn.setRequestProperty("Accept", "application/json"); - conn.setDoOutput(true); - conn.setDoInput(true); - conn.setConnectTimeout(10000); - conn.setReadTimeout(30000); - - // 写入请求参数 - try (OutputStream os = conn.getOutputStream(); - OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8)) { - osw.write(params.toString()); - osw.flush(); - } - - // 读取响应 - int statusCode = conn.getResponseCode(); - StringBuilder response = new StringBuilder(); - - try { - BufferedReader reader; - InputStream inputStream; - - if (statusCode >= 200 && statusCode < 300) { - inputStream = conn.getInputStream(); - } else { - // 对于错误响应,尝试读取错误流 - inputStream = conn.getErrorStream(); - // 如果错误流为null,尝试读取正常流(某些服务器可能将错误信息放在正常流中) - if (inputStream == null) { - inputStream = conn.getInputStream(); - } - } - - // 如果输入流仍然为null,说明无法读取响应 - if (inputStream == null) { - log.warn("无法读取HTTP响应流,状态码: {}", statusCode); - response.append("无法读取响应内容"); - } else { - reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); - String line; - while ((line = reader.readLine()) != null) { - response.append(line); - } - reader.close(); - } - } catch (Exception e) { - log.error("读取HTTP响应失败", e); - response.append("读取响应失败: ").append(e.getMessage()); - } - - String responseStr = response.toString(); - log.info("刷新访问令牌响应: statusCode={}, response={}", statusCode, responseStr); - - if (statusCode < 200 || statusCode >= 300) { - throw new RuntimeException("刷新访问令牌失败: HTTP " + statusCode + ", response=" + responseStr); - } - - return JSON.parseObject(responseStr); - } catch (Exception e) { - log.error("刷新访问令牌失败", e); - throw new RuntimeException("刷新访问令牌失败: " + e.getMessage(), e); - } - } - - /** - * 发送HTTP请求 - * - * @param method 请求方法(GET/POST/PUT/DELETE) - * @param url 请求URL - * @param accessToken 访问令牌 - * @param body 请求体(JSON字符串,GET请求可为null) - * @return 响应JSON对象 - */ - public static JSONObject httpRequest(String method, String url, String accessToken, String body) { - try { - log.debug("发送HTTP请求: method={}, url={}", method, url); - - URL requestUrl = new URL(url); - java.net.Proxy proxy = java.net.Proxy.NO_PROXY; - HttpURLConnection conn = (HttpURLConnection) requestUrl.openConnection(proxy); - conn.setRequestMethod(method); - conn.setRequestProperty("Accept", "application/json"); - conn.setRequestProperty("Authorization", "Bearer " + accessToken); - conn.setConnectTimeout(10000); - conn.setReadTimeout(30000); - - if ("POST".equals(method) || "PUT".equals(method) || "PATCH".equals(method)) { - conn.setRequestProperty("Content-Type", "application/json"); - conn.setDoOutput(true); - - if (body != null && !body.isEmpty()) { - try (OutputStream os = conn.getOutputStream(); - OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8)) { - osw.write(body); - osw.flush(); - } - } - } - - // 读取响应 - int statusCode = conn.getResponseCode(); - StringBuilder response = new StringBuilder(); - - try { - BufferedReader reader; - InputStream inputStream; - - if (statusCode >= 200 && statusCode < 300) { - inputStream = conn.getInputStream(); - } else { - // 对于错误响应,尝试读取错误流 - inputStream = conn.getErrorStream(); - // 如果错误流为null,尝试读取正常流(某些服务器可能将错误信息放在正常流中) - if (inputStream == null) { - inputStream = conn.getInputStream(); - } - } - - // 如果输入流仍然为null,说明无法读取响应 - if (inputStream == null) { - log.warn("无法读取HTTP响应流,状态码: {}", statusCode); - response.append("无法读取响应内容"); - } else { - reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); - String line; - while ((line = reader.readLine()) != null) { - response.append(line); - } - reader.close(); - } - } catch (Exception e) { - log.error("读取HTTP响应失败", e); - response.append("读取响应失败: ").append(e.getMessage()); - } - - String responseStr = response.toString(); - log.info("HTTP响应: statusCode={}, url={}, response={}", statusCode, url, responseStr); - - if (statusCode < 200 || statusCode >= 300) { - String errorMsg = String.format("HTTP请求失败: statusCode=%d, url=%s, response=%s", - statusCode, url, responseStr); - log.error(errorMsg); - throw new RuntimeException(errorMsg); - } - - if (responseStr == null || responseStr.trim().isEmpty()) { - return new JSONObject(); - } - - return JSON.parseObject(responseStr); - } catch (Exception e) { - log.error("HTTP请求失败: method={}, url={}", method, url, e); - throw new RuntimeException("HTTP请求失败: " + e.getMessage(), e); - } - } -} -