This commit is contained in:
van
2026-03-23 17:04:37 +08:00
parent 458b84f913
commit 9bb7cfc7fb
24 changed files with 1055 additions and 3405 deletions

View File

@@ -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平台可能有缓存

View File

@@ -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. **应用IDAppId** 是否与配置文件中的 `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平台的应用配置截图隐藏敏感信息

View File

@@ -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` 会依次尝试
1AirSheet 路径 → 2**KSheet在线表格路径**(同一 `fileId` 当作 `file_token`)→ 3v7 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.cnWPS365** 的 OAuth 与 APItoken 不能直接访问金山文档。
- 要在后端读/写金山文档里的表格,需要接 **金山文档开放平台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 下当前没有可用的智能表格AirSheetREST 读/写接口。
- **可行方案**:用 **文件列表接口拿 file_token**,用 **KSheet 读/写接口**readCells / updateCells操作该文档若文档在金山文档需改用金山文档开放平台的 API。

View File

@@ -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 <token>`)。
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` 访问表格。

View File

@@ -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. **数据量限制**:一次读取的数据量不要太大,建议分批读取

View File

@@ -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 + RedisToken存储
- **前端**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/)

View File

@@ -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<String> 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("<!DOCTYPE html><html lang='zh-CN'><head><meta charset='UTF-8'><title>")
.append(success ? "授权成功" : "授权失败")
.append("</title></head><body style='font-family:sans-serif;text-align:center;padding:40px'>");
html.append("<h2>").append(success ? "✓ 授权成功" : "✗ 授权失败").append("</h2>");
html.append("<p>").append(message).append("</p>");
html.append("<script>");
html.append("if(window.opener){window.opener.postMessage({type:'kdocs_oauth_callback',success:")
.append(success).append(",message:'").append(esc).append("'");
if (success && !uid.isEmpty()) {
html.append(",userId:'").append(uid).append("'");
}
html.append("},'*');}");
html.append("setTimeout(function(){if(window.opener)window.close();},2000);");
html.append("</script></body></html>");
return new ResponseEntity<>(html.toString(), headers, HttpStatus.OK);
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<List<Object>> values = (List<List<Object>>) 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<String, Object> 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<String, Object> 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<Map<String, Object>> updates = (List<Map<String, Object>>) 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());
}
}
}

View File

@@ -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参数会自动使用配置中的完整URLhttps://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("<!DOCTYPE html>");
html.append("<html lang='zh-CN'>");
html.append("<head>");
html.append("<meta charset='UTF-8'>");
html.append("<meta name='viewport' content='width=device-width, initial-scale=1.0'>");
html.append("<title>WPS365授权").append(success ? "成功" : "失败").append("</title>");
html.append("<style>");
html.append("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; ");
html.append("display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; ");
html.append("background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }");
html.append(".container { background: white; padding: 40px; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); text-align: center; max-width: 500px; }");
html.append(".icon { font-size: 64px; margin-bottom: 20px; }");
html.append(".success { color: #52c41a; }");
html.append(".error { color: #ff4d4f; }");
html.append(".message { font-size: 16px; color: #333; margin-bottom: 20px; line-height: 1.6; }");
html.append(".info { font-size: 14px; color: #666; margin-top: 20px; padding: 15px; background: #f5f5f5; border-radius: 5px; text-align: left; }");
html.append("</style>");
html.append("</head>");
html.append("<body>");
html.append("<div class='container'>");
if (success) {
html.append("<div class='icon success'>✓</div>");
html.append("<h2 style='color: #52c41a; margin-bottom: 10px;'>授权成功</h2>");
} else {
html.append("<div class='icon error'>✗</div>");
html.append("<h2 style='color: #ff4d4f; margin-bottom: 10px;'>授权失败</h2>");
}
html.append("<div class='message'>").append(message).append("</div>");
// 显示Token信息仅成功时
if (success && tokenInfo != null) {
html.append("<div class='info'>");
html.append("<strong>授权信息:</strong><br>");
if (tokenInfo.getUserId() != null) {
html.append("用户ID: ").append(tokenInfo.getUserId()).append("<br>");
}
if (tokenInfo.getExpiresIn() != null) {
html.append("有效期: ").append(tokenInfo.getExpiresIn()).append(" 秒<br>");
}
html.append("</div>");
}
html.append("<p style='color: #999; font-size: 14px; margin-top: 20px;'>窗口将在3秒后自动关闭...</p>");
html.append("</div>");
html.append("<script>");
html.append("// 通知父窗口授权结果");
html.append("if (window.opener) {");
html.append(" window.opener.postMessage({");
html.append(" type: 'wps365_oauth_callback',");
html.append(" success: ").append(success).append(",");
html.append(" message: '").append(message.replace("'", "\\'").replace("\n", "\\n").replace("\r", "\\r")).append("'");
if (success && tokenInfo != null && tokenInfo.getUserId() != null) {
html.append(",");
html.append(" userId: '").append(tokenInfo.getUserId()).append("'");
}
html.append(" }, '*');");
html.append("}");
html.append("// 3秒后自动关闭窗口");
html.append("setTimeout(function() {");
html.append(" if (window.opener) {");
html.append(" window.close();");
html.append(" } else {");
html.append(" window.location.href = '/';");
html.append(" }");
html.append("}, 3000);");
html.append("</script>");
html.append("</body>");
html.append("</html>");
return html.toString();
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<List<Object>> values = (List<List<Object>>) 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<String, Object> 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<String, Object> 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<Map<String, Object>> updates = (List<Map<String, Object>>) 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<String, Object> 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<List<Object>> values = (List<List<Object>>) 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());
}
}
}

View File

@@ -9,46 +9,13 @@ tencent:
push: push:
# 延迟时间分钟默认10分钟 # 延迟时间分钟默认10分钟
minutes: 10 minutes: 10
# WPS365开放平台配置 # 金山文档开放平台个人云https://developer.kdocs.cn
# 文档地址https://open.wps.cn/ kdocs:
wps365: api-host: https://developer.kdocs.cn
# 应用IDAppId- 需要在WPS365开放平台申请 # 在开发者后台创建应用后填写 app_id / app_key
app-id: AK20260114NNQJKV app-id: ""
# 应用密钥AppKey- 需要在WPS365开放平台申请注意保密 app-key: ""
app-key: 4c58bc1642e5e8fa731f75af9370496a # 与后台登记的回调一致,建议使用独立路径(勿被前端路由拦截)
# 授权回调地址需要在WPS365开放平台配置授权域名 redirect-uri: https://jarvis.van333.cn/kdocs-callback
# 注意:使用 /wps365-callback 路径,避免前端路由拦截 # 逗号分隔须与应用申请权限一致https://developer.kdocs.cn/server/guide/permission.html
redirect-uri: https://jarvis.van333.cn/wps365-callback scope: user_basic,access_personal_files,edit_personal_files
# 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

View File

@@ -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 <a href="https://developer.kdocs.cn">developer.kdocs.cn</a>
*/
@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;
}
}

View File

@@ -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);
/** 应用IDAppId */
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;
}
}

View File

@@ -3,39 +3,33 @@ package com.ruoyi.jarvis.domain.dto;
import java.io.Serializable; import java.io.Serializable;
/** /**
* WPS365 Token信息 * 金山文档 OAuth 令牌 Redis
*
* @author system
*/ */
public class WPS365TokenInfo implements Serializable { public class KdocsTokenInfo implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** 访问令牌 */
private String accessToken; private String accessToken;
/** 刷新令牌 */
private String refreshToken; private String refreshToken;
/** 令牌类型 */
private String tokenType; private String tokenType;
/** 过期时间(秒) */
private Integer expiresIn; private Integer expiresIn;
/** 作用域 */
private String scope; private String scope;
/** 本系统用于关联 Redis 的用户标识,通常取金山 open_id */
/** 用户ID */
private String userId; private String userId;
/** 创建时间戳(毫秒) */
private Long createTime; private Long createTime;
public WPS365TokenInfo() { public KdocsTokenInfo() {
this.createTime = System.currentTimeMillis(); 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() { public String getAccessToken() {
return accessToken; return accessToken;
} }
@@ -91,18 +85,4 @@ public class WPS365TokenInfo implements Serializable {
public void setCreateTime(Long createTime) { public void setCreateTime(Long createTime) {
this.createTime = 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);
}
} }

View File

@@ -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);
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.jarvis.service;
import com.alibaba.fastjson2.JSONObject;
import java.util.List;
import java.util.Map;
/**
* 金山文档个人云 Open APIdeveloper.kdocs.cn
*/
public interface IKdocsOpenApiService {
JSONObject getUserInfoFlat(String accessToken);
JSONObject getFileList(String accessToken, Map<String, Object> 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<List<Object>> values);
JSONObject createSheet(String accessToken, String fileToken, String sheetName);
JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List<Map<String, Object>> updates);
}

View File

@@ -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<String, Object> 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<List<Object>> 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<Map<String, Object>> 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<List<Object>> values);
}

View File

@@ -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);
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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<String, Object> 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<List<Object>> values) {
JSONObject body = new JSONObject();
body.put("range", range);
JSONArray valuesArray = new JSONArray();
if (values != null) {
for (List<Object> 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<Map<String, Object>> updates) {
JSONObject body = new JSONObject();
JSONArray updatesArray = new JSONArray();
if (updates != null) {
for (Map<String, Object> update : updates) {
JSONObject u = new JSONObject();
u.put("range", update.get("range"));
@SuppressWarnings("unchecked")
List<List<Object>> values = (List<List<Object>>) update.get("values");
JSONArray valuesArray = new JSONArray();
if (values != null) {
for (List<Object> 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());
}
}

View File

@@ -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<String, Object> 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.filesv7 可能返回 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<List<Object>> 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<Object> 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<Map<String, Object>> 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<String, Object> update : updates) {
JSONObject updateObj = new JSONObject();
updateObj.put("range", update.get("range"));
// 将values转换为JSONArray
@SuppressWarnings("unchecked")
List<List<Object>> values = (List<List<Object>>) update.get("values");
JSONArray valuesArray = new JSONArray();
if (values != null) {
for (List<Object> 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);
// 方案1AirSheet 路径 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<List<Object>> 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<Object> 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();
// 方案1AirSheet 路径 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;
}
}
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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 APIcode 必须为 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();
}
}

View File

@@ -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);
}
}
}