Compare commits
36 Commits
2fe78ec192
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa36c4966 | ||
|
|
269e8e48a7 | ||
|
|
dde274acba | ||
|
|
0297c6e131 | ||
|
|
6394658a70 | ||
|
|
dafd63a9ec | ||
|
|
5a8e1198cf | ||
|
|
6257d816e9 | ||
|
|
3336eeb6aa | ||
|
|
18f541fdf7 | ||
|
|
9cb5e6a488 | ||
|
|
2ce45e5ccc | ||
|
|
c837917be3 | ||
|
|
4379277a08 | ||
|
|
ff9ab96833 | ||
|
|
2b74f77419 | ||
|
|
76500642eb | ||
|
|
a41c9ceaf9 | ||
|
|
7f7bec8d29 | ||
|
|
8802e68106 | ||
|
|
2fb283c3f3 | ||
|
|
f044417d8d | ||
|
|
ba1e025326 | ||
|
|
61f66d90b4 | ||
|
|
e93e7cec46 | ||
|
|
d5df4108a6 | ||
|
|
c58adef068 | ||
|
|
b43daf2965 | ||
|
|
ab062b3b5a | ||
|
|
01b19602b6 | ||
|
|
01e5312ccc | ||
|
|
00f0c38672 | ||
|
|
ba6f250914 | ||
|
|
fb00ecbcb6 | ||
|
|
9483ebf1f5 | ||
|
|
440df7d538 |
119
doc/WPS365回调地址配置说明.md
Normal file
119
doc/WPS365回调地址配置说明.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 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平台可能有缓存)
|
||||
|
||||
259
doc/WPS365授权错误排查指南.md
Normal file
259
doc/WPS365授权错误排查指南.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# WPS365 授权错误排查指南
|
||||
|
||||
## 常见错误类型
|
||||
|
||||
### 1. invalid_request (40000001) - redirect_uri不匹配
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 40000001,
|
||||
"msg": "invalid_request",
|
||||
"debug": {
|
||||
"desc": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. The 'redirect_uri' parameter does not match any of the OAuth 2.0 Client's pre-registered redirect urls."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误含义**:redirect_uri参数值与WPS365平台配置的回调地址不一致
|
||||
|
||||
### 2. invalid_scope (40000005) - scope权限无效 ⚠️
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 40000005,
|
||||
"msg": "invalid_scope",
|
||||
"debug": {
|
||||
"desc": "The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'file.read,ksheet.read,user.info'."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误含义**:请求的scope权限格式不正确,或者应用未申请这些权限
|
||||
|
||||
## 错误含义总览
|
||||
|
||||
授权错误可能由以下原因导致:
|
||||
|
||||
1. **缺少必需参数** - 授权请求中缺少某个必需的参数
|
||||
2. **参数值无效** - 某个参数的值格式不正确
|
||||
3. **参数重复** - 某个参数在请求中出现了多次
|
||||
4. **redirect_uri不匹配** - redirect_uri参数值与WPS365平台配置的回调地址不一致
|
||||
5. **scope无效** - scope权限格式不正确或未申请
|
||||
|
||||
## 排查步骤
|
||||
|
||||
### 1. 查看后端日志
|
||||
|
||||
查看后端日志中的授权URL和参数清单,例如:
|
||||
|
||||
```
|
||||
生成授权URL: https://openapi.wps.cn/oauth2/auth?client_id=xxx&redirect_uri=...
|
||||
📋 授权请求参数清单:
|
||||
- client_id: AK20260114NNQJKV
|
||||
- redirect_uri: https://jarvis.van333.cn/wps365-callback
|
||||
- response_type: code
|
||||
- scope: file.read,ksheet.read,user.info
|
||||
- state: xxxxx
|
||||
```
|
||||
|
||||
### 2. 检查参数名是否正确
|
||||
|
||||
**问题**:WPS365可能使用 `app_id` 而不是标准的 `client_id`
|
||||
|
||||
**解决方案**:
|
||||
- 如果使用 `client_id` 报错,尝试改为 `app_id`
|
||||
- 查看WPS365官方文档确认正确的参数名
|
||||
|
||||
**当前代码使用**:`client_id`(标准OAuth2参数名)
|
||||
|
||||
### 3. 检查必需参数是否齐全
|
||||
|
||||
授权请求必须包含以下参数:
|
||||
|
||||
| 参数名 | 是否必需 | 说明 | 当前值 |
|
||||
|--------|---------|------|--------|
|
||||
| `client_id` 或 `app_id` | ✅ 必需 | 应用ID | 从配置读取 |
|
||||
| `redirect_uri` | ✅ 必需 | 回调地址 | 从配置读取 |
|
||||
| `response_type` | ✅ 必需 | 固定值 `code` | `code` |
|
||||
| `scope` | ✅ 必需 | 权限范围 | `file.read,ksheet.read,user.info` |
|
||||
| `state` | ⚠️ 推荐 | 防CSRF攻击 | 自动生成 |
|
||||
|
||||
### 4. 检查redirect_uri是否匹配
|
||||
|
||||
这是最常见的错误原因。必须确保:
|
||||
|
||||
1. **协议一致**:必须是 `https://`(不能是 `http://`)
|
||||
2. **域名一致**:必须是 `jarvis.van333.cn`(不能有 `www` 前缀)
|
||||
3. **路径一致**:必须是 `/wps365-callback`(不能有末尾斜杠)
|
||||
4. **端口一致**:使用默认443端口,不需要写端口号
|
||||
|
||||
**在WPS365平台配置的回调地址必须与日志中的redirect_uri完全一致**
|
||||
|
||||
### 5. 检查scope权限是否已申请(重要!)
|
||||
|
||||
**如果遇到 `invalid_scope` 错误,请按以下步骤排查:**
|
||||
|
||||
#### 5.1 检查WPS365平台后台的scope格式
|
||||
|
||||
1. 登录WPS365开放平台
|
||||
2. 进入"开发配置" > "权限管理"
|
||||
3. 查看已申请的权限列表,**注意权限的格式**:
|
||||
- 必须以 `kso.` 开头,如:`kso.file.read`、`kso.file.readwrite`
|
||||
- 不是 `file.read` 或 `ksheet.read`(这些格式不存在)
|
||||
- 根据官方文档:https://open.wps.cn/documents/app-integration-dev/wps365/server/
|
||||
|
||||
#### 5.2 检查scope分隔符
|
||||
|
||||
**根据WPS365官方文档,必须使用英文逗号分隔**(不是空格):
|
||||
|
||||
| 分隔符 | 示例 | 说明 |
|
||||
|--------|------|------|
|
||||
| **逗号(正确)** | `kso.file.readwrite,kso.doclib.readwrite` | ✅ WPS365官方要求 |
|
||||
| **空格(错误)** | `kso.file.readwrite kso.doclib.readwrite` | ❌ 会导致invalid_scope |
|
||||
| **逗号+空格** | `kso.file.readwrite, kso.doclib.readwrite` | ⚠️ 可能支持,但不推荐 |
|
||||
|
||||
**重要**:WPS365官方文档明确要求使用英文逗号 `,` 分隔,不能使用空格。
|
||||
|
||||
#### 5.3 在配置文件中指定scope
|
||||
|
||||
在 `application.yml` 中添加 `scope` 配置,**必须使用英文逗号分隔**:
|
||||
|
||||
```yaml
|
||||
wps365:
|
||||
# 根据WPS365平台后台"权限管理"中显示的实际权限名称配置
|
||||
# 使用英文逗号分隔(WPS365官方要求)
|
||||
# 权限名称必须以 kso. 开头
|
||||
scope: kso.file.readwrite
|
||||
```
|
||||
|
||||
多个权限示例:
|
||||
|
||||
```yaml
|
||||
wps365:
|
||||
# 多个权限用英文逗号分隔,不能有空格
|
||||
# 权限名称必须以 kso. 开头
|
||||
scope: kso.file.read,kso.file.readwrite
|
||||
```
|
||||
|
||||
#### 5.4 确认权限已申请且名称正确
|
||||
|
||||
**关键步骤**:
|
||||
|
||||
1. **登录WPS365开放平台**
|
||||
- 访问:https://open.wps.cn/
|
||||
- 进入你的应用管理页面
|
||||
|
||||
2. **查看已申请的权限**
|
||||
- 进入"开发配置" > "权限管理"
|
||||
- 查看已申请权限的**准确名称**(注意大小写、分隔符、命名空间)
|
||||
|
||||
3. **对比权限名称**
|
||||
- 权限名称必须与后台显示的**完全一致**
|
||||
- 例如:如果后台显示的是 `kso.doclib.readwrite`,不能写成 `kso.doclib.readWrite` 或 `kso_doclib_readwrite`
|
||||
|
||||
4. **常见权限名称示例**(仅供参考,以平台后台实际显示为准):
|
||||
- `kso.doclib.readwrite` - 文档库读写权限
|
||||
- `kso.doclib.read` - 文档库读取权限
|
||||
- `ksheet.read` - 在线表格读取权限(如果支持)
|
||||
|
||||
**重要提示**:
|
||||
- ⚠️ `file.read`、`ksheet.read`、`user.info` 这些权限名称可能不存在
|
||||
- ✅ 必须查看WPS365平台后台实际显示的权限名称
|
||||
- ✅ 权限名称必须完全匹配(包括大小写、分隔符、命名空间)
|
||||
- ✅ 必须使用英文逗号分隔多个权限
|
||||
|
||||
### 6. 尝试修改参数名
|
||||
|
||||
如果确认所有参数都正确,但仍然报错,可能是参数名问题:
|
||||
|
||||
**测试方案1**:将 `client_id` 改为 `app_id`
|
||||
|
||||
修改 `WPS365OAuthServiceImpl.java` 中的代码:
|
||||
```java
|
||||
// 原代码
|
||||
authUrl.append("?client_id=").append(appId);
|
||||
|
||||
// 改为
|
||||
authUrl.append("?app_id=").append(appId);
|
||||
```
|
||||
|
||||
**测试方案2**:同时提供两个参数(如果WPS365支持)
|
||||
```java
|
||||
authUrl.append("?app_id=").append(appId);
|
||||
authUrl.append("&client_id=").append(appId);
|
||||
```
|
||||
|
||||
### 7. 检查URL编码
|
||||
|
||||
确保 `redirect_uri` 参数正确进行了URL编码:
|
||||
- 空格应该编码为 `%20`
|
||||
- 斜杠 `/` 应该编码为 `%2F`
|
||||
- 冒号 `:` 应该编码为 `%3A`
|
||||
|
||||
查看日志中的"编码后"值,确认编码是否正确。
|
||||
|
||||
### 8. 验证WPS365平台配置
|
||||
|
||||
登录WPS365开放平台,检查:
|
||||
|
||||
1. **应用ID(AppId)** 是否与配置文件中的 `app-id` 一致
|
||||
2. **回调地址配置** 是否与日志中的 `redirect_uri` 完全一致
|
||||
3. **权限配置** 是否已申请所需的scope权限
|
||||
4. **应用状态** 是否为"已上线"或"测试中"
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 参数名应该用 `client_id` 还是 `app_id`?
|
||||
|
||||
**A**: 根据WPS365官方文档确认。标准OAuth2使用 `client_id`,但某些平台可能使用 `app_id`。如果 `client_id` 不工作,尝试 `app_id`。
|
||||
|
||||
### Q2: 为什么redirect_uri明明配置了还是报错?
|
||||
|
||||
**A**: 最常见的原因是:
|
||||
- 平台配置的回调地址与代码中的不完全一致(多了/少了斜杠、协议不同等)
|
||||
- 平台配置未保存或未生效
|
||||
- 使用了错误的配置环境(开发/生产)
|
||||
|
||||
### Q3: scope权限在哪里申请?
|
||||
|
||||
**A**: 在WPS365开放平台的"开发配置" > "权限管理"中申请。
|
||||
|
||||
### Q4: 如何确认参数是否正确?
|
||||
|
||||
**A**: 查看后端日志,会打印完整的授权URL和参数清单。对比WPS365平台配置,确保完全一致。
|
||||
|
||||
### Q5: 遇到 `invalid_scope` 错误怎么办? ⚠️
|
||||
|
||||
**A**: 这是scope格式或权限问题,按以下步骤排查:
|
||||
|
||||
1. **查看WPS365平台后台的权限格式**
|
||||
- 进入"开发配置" > "权限管理"
|
||||
- 查看已申请权限的**确切格式**(包括分隔符、大小写)
|
||||
|
||||
2. **尝试不同的scope格式**
|
||||
- 空格分隔:`file.read ksheet.read user.info`(当前默认)
|
||||
- 逗号分隔:`file.read,ksheet.read,user.info`
|
||||
- 在 `application.yml` 中配置 `scope` 参数进行测试
|
||||
|
||||
3. **确认权限已申请且已审核通过**
|
||||
- 权限必须处于"已通过"或"可用"状态
|
||||
- 如果权限未申请,需要先申请并等待审核
|
||||
|
||||
4. **查看后端日志中的scope值**
|
||||
- 日志会显示实际使用的scope格式
|
||||
- 对比WPS365平台显示的格式,确保完全一致
|
||||
|
||||
## 调试建议
|
||||
|
||||
1. **启用DEBUG日志**:在 `application.yml` 中设置日志级别为DEBUG
|
||||
2. **查看完整授权URL**:复制日志中的授权URL,在浏览器中访问,查看具体错误
|
||||
3. **对比官方文档**:查看WPS365官方OAuth文档,确认参数名和格式
|
||||
4. **联系WPS365技术支持**:如果所有参数都正确但仍报错,可能是平台问题
|
||||
|
||||
## 下一步
|
||||
|
||||
如果按照以上步骤排查后仍然报错,请提供:
|
||||
1. 后端日志中的完整授权URL和参数清单
|
||||
2. WPS365平台配置的回调地址截图
|
||||
3. WPS365平台的应用配置截图(隐藏敏感信息)
|
||||
|
||||
271
doc/WPS365读取在线表格配置指南.md
Normal file
271
doc/WPS365读取在线表格配置指南.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# 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. **数据量限制**:一次读取的数据量不要太大,建议分批读取
|
||||
|
||||
270
doc/WPS365集成使用说明.md
Normal file
270
doc/WPS365集成使用说明.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# WPS365 在线表格API集成使用说明
|
||||
|
||||
## 概述
|
||||
|
||||
本功能实现了WPS365在线表格(KSheet)的完整API集成,支持用户授权、文档查看和编辑等功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
1. **OAuth用户授权**:支持WPS365标准的OAuth 2.0授权流程
|
||||
2. **Token管理**:自动保存和管理访问令牌,支持Token刷新
|
||||
3. **文件管理**:获取文件列表、文件信息
|
||||
4. **工作表管理**:获取工作表列表、创建数据表
|
||||
5. **单元格编辑**:支持读取和更新单元格数据,支持批量更新
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 1. 在WPS365开放平台注册应用
|
||||
|
||||
1. 访问 [WPS365开放平台](https://open.wps.cn/)
|
||||
2. 注册成为服务商并创建应用
|
||||
3. 获取 `app_id` 和 `app_key`
|
||||
4. 配置授权回调地址(需要与配置文件中的 `redirect-uri` 一致)
|
||||
|
||||
### 1.1 配置应用权限(重要!)
|
||||
|
||||
在WPS365开放平台的应用配置中,需要确保开启以下权限:
|
||||
|
||||
#### 基础权限
|
||||
- **文件读取权限** (`file.read` 或 `file.readwrite`)
|
||||
- **用户信息权限** (`user.info`)
|
||||
|
||||
#### 在线表格(KSheet)相关权限
|
||||
根据图片中的应用能力配置页面,如果需要读取在线表格内容,建议:
|
||||
|
||||
1. **进入"应用能力"配置页面**
|
||||
- 在左侧导航栏选择"应用能力" > "应用能力"
|
||||
|
||||
2. **确保相关能力已开启**
|
||||
- 虽然图片中显示的是"多维表格"插件相关能力,但读取在线表格内容主要依赖:
|
||||
- **文件读取权限**(在"权限管理"中配置)
|
||||
- **API调用权限**(确保应用有调用OpenAPI的权限)
|
||||
|
||||
3. **权限管理配置**
|
||||
- 进入"开发配置" > "权限管理"
|
||||
- 确保添加了以下权限:
|
||||
- `ksheet:read` - 读取在线表格数据
|
||||
- `ksheet:write` - 写入在线表格数据(如果需要编辑)
|
||||
- `file:read` - 读取文件
|
||||
- `file:write` - 写入文件(如果需要编辑)
|
||||
|
||||
4. **事件与回调配置**
|
||||
- 进入"开发配置" > "事件与回调"
|
||||
- 配置回调地址:`https://your-domain.com/wps365-callback`
|
||||
- 确保回调地址验证通过(支持GET和POST请求)
|
||||
|
||||
### 2. 更新配置文件
|
||||
|
||||
编辑 `application-dev.yml`(或对应的环境配置文件),添加WPS365配置:
|
||||
|
||||
```yaml
|
||||
wps365:
|
||||
# 应用ID(从WPS365开放平台获取)
|
||||
app-id: YOUR_APP_ID
|
||||
# 应用密钥(从WPS365开放平台获取)
|
||||
app-key: YOUR_APP_KEY
|
||||
# 授权回调地址(需要在WPS365开放平台配置)
|
||||
# 注意:使用 /wps365-callback 路径,避免前端路由拦截
|
||||
redirect-uri: https://your-domain.com/wps365-callback
|
||||
# API基础地址(一般不需要修改)
|
||||
api-base-url: https://openapi.wps.cn/api/v1
|
||||
# OAuth授权地址(一般不需要修改)
|
||||
oauth-url: https://openapi.wps.cn/oauth2/auth
|
||||
# 获取Token地址(一般不需要修改)
|
||||
token-url: https://openapi.wps.cn/oauth2/token
|
||||
# 刷新Token地址(一般不需要修改)
|
||||
refresh-token-url: https://openapi.wps.cn/oauth2/token
|
||||
```
|
||||
|
||||
**重要提示**:
|
||||
- 回调地址使用 `/wps365-callback` 而不是 `/jarvis/wps365/oauth/callback`
|
||||
- 这样可以避免前端路由拦截,确保OAuth回调能正常访问
|
||||
- 在WPS365开放平台配置时,只需配置域名(如:`jarvis.van333.cn`),回调路径会自动使用配置中的完整URL
|
||||
|
||||
## API接口说明
|
||||
|
||||
### 1. 获取授权URL
|
||||
|
||||
**接口**: `GET /jarvis/wps365/authUrl`
|
||||
|
||||
**参数**:
|
||||
- `state` (可选): 状态参数,用于防止CSRF攻击
|
||||
|
||||
**返回**: 授权URL,用户需要访问此URL完成授权
|
||||
|
||||
**示例**:
|
||||
```javascript
|
||||
// 前端调用
|
||||
import { getWPS365AuthUrl } from '@/api/jarvis/wps365'
|
||||
|
||||
const response = await getWPS365AuthUrl()
|
||||
const authUrl = response.data
|
||||
// 打开授权页面
|
||||
window.open(authUrl, '_blank')
|
||||
```
|
||||
|
||||
### 2. OAuth回调处理
|
||||
|
||||
**接口**: `GET /wps365-callback`
|
||||
|
||||
**参数**:
|
||||
- `code`: 授权码(由WPS365回调时自动传入)
|
||||
- `state`: 状态参数(可选)
|
||||
- `error`: 错误码(授权失败时)
|
||||
- `error_description`: 错误描述(授权失败时)
|
||||
|
||||
**说明**:
|
||||
- 此接口处理WPS365的授权回调,自动获取并保存Token
|
||||
- 返回HTML页面,显示授权结果
|
||||
- 使用 `/wps365-callback` 路径避免前端路由拦截
|
||||
- 授权成功后会显示成功页面,3秒后自动关闭窗口
|
||||
|
||||
### 3. 获取Token状态
|
||||
|
||||
**接口**: `GET /jarvis/wps365/tokenStatus`
|
||||
|
||||
**参数**:
|
||||
- `userId`: 用户ID
|
||||
|
||||
**返回**: Token状态信息(是否授权、是否有效等)
|
||||
|
||||
### 4. 刷新Token
|
||||
|
||||
**接口**: `POST /jarvis/wps365/refreshToken`
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"refreshToken": "refresh_token_value"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 获取用户信息
|
||||
|
||||
**接口**: `GET /jarvis/wps365/userInfo`
|
||||
|
||||
**参数**:
|
||||
- `userId`: 用户ID
|
||||
|
||||
### 6. 获取文件列表
|
||||
|
||||
**接口**: `GET /jarvis/wps365/files`
|
||||
|
||||
**参数**:
|
||||
- `userId`: 用户ID
|
||||
- `page`: 页码(默认1)
|
||||
- `pageSize`: 每页数量(默认20)
|
||||
|
||||
### 7. 获取工作表列表
|
||||
|
||||
**接口**: `GET /jarvis/wps365/sheets`
|
||||
|
||||
**参数**:
|
||||
- `userId`: 用户ID
|
||||
- `fileToken`: 文件token
|
||||
|
||||
### 8. 读取单元格数据
|
||||
|
||||
**接口**: `GET /jarvis/wps365/readCells`
|
||||
|
||||
**参数**:
|
||||
- `userId`: 用户ID
|
||||
- `fileToken`: 文件token
|
||||
- `sheetIdx`: 工作表索引(从0开始)
|
||||
- `range`: 单元格范围(如:A1:B10,可选)
|
||||
|
||||
### 9. 更新单元格数据
|
||||
|
||||
**接口**: `POST /jarvis/wps365/updateCells`
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"userId": "user_id",
|
||||
"fileToken": "file_token",
|
||||
"sheetIdx": 0,
|
||||
"range": "A1:B2",
|
||||
"values": [
|
||||
["值1", "值2"],
|
||||
["值3", "值4"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 10. 批量更新单元格数据
|
||||
|
||||
**接口**: `POST /jarvis/wps365/batchUpdateCells`
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"userId": "user_id",
|
||||
"fileToken": "file_token",
|
||||
"sheetIdx": 0,
|
||||
"updates": [
|
||||
{
|
||||
"range": "A1:B2",
|
||||
"values": [["值1", "值2"], ["值3", "值4"]]
|
||||
},
|
||||
{
|
||||
"range": "C1:D2",
|
||||
"values": [["值5", "值6"], ["值7", "值8"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 1. 用户授权流程
|
||||
|
||||
1. 前端调用 `/jarvis/wps365/authUrl` 获取授权URL
|
||||
2. 在新窗口打开授权URL,用户完成授权
|
||||
3. WPS365会回调到配置的 `redirect-uri`,自动处理授权码
|
||||
4. 系统自动获取并保存Token到Redis
|
||||
|
||||
### 2. 编辑文档流程
|
||||
|
||||
1. 调用 `/jarvis/wps365/files` 获取文件列表
|
||||
2. 选择要编辑的文件,调用 `/jarvis/wps365/sheets` 获取工作表列表
|
||||
3. 调用 `/jarvis/wps365/readCells` 读取现有数据
|
||||
4. 修改数据后,调用 `/jarvis/wps365/updateCells` 更新数据
|
||||
|
||||
### 3. Token刷新
|
||||
|
||||
- Token过期前,系统会自动尝试刷新
|
||||
- 也可以手动调用 `/jarvis/wps365/refreshToken` 刷新Token
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **用户权限**:只有文档的所有者或被授予编辑权限的用户才能编辑文档
|
||||
2. **Token管理**:Token存储在Redis中,有效期30天。过期后需要重新授权
|
||||
3. **API限制**:注意WPS365的API调用频率限制
|
||||
4. **文件Token**:WPS365使用 `file_token` 而不是文件ID,需要从文件列表中获取
|
||||
|
||||
## 前端页面
|
||||
|
||||
访问 `/jarvis/wps365` 可以打开WPS365管理页面,包含:
|
||||
- 授权状态显示
|
||||
- 用户信息查看
|
||||
- 文件列表浏览
|
||||
- 在线编辑表格功能
|
||||
|
||||
## 错误处理
|
||||
|
||||
- **未授权**:返回错误提示,引导用户完成授权
|
||||
- **Token过期**:自动尝试刷新Token,刷新失败则提示重新授权
|
||||
- **权限不足**:返回错误码10003,提示用户没有编辑权限
|
||||
|
||||
## 技术实现
|
||||
|
||||
- **后端**:Spring Boot + Redis(Token存储)
|
||||
- **前端**:Vue.js + Element UI
|
||||
- **HTTP客户端**:HttpURLConnection(不使用代理,直接连接)
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [WPS365开放平台文档](https://open.wps.cn/)
|
||||
- [用户授权流程](https://open.wps.cn/documents/app-integration-dev/wps365/server/certification-authorization/user-authorization/flow)
|
||||
- [KSheet API文档](https://developer.kdocs.cn/server/ksheet/)
|
||||
|
||||
@@ -756,7 +756,7 @@ public class TencentDocController extends BaseController {
|
||||
successLogs.add(successLog);
|
||||
|
||||
// 手动执行时,batchId为null
|
||||
sendWeChatNotification(successLogs, 1, 0, 0, null, fileId, sheetId, true);
|
||||
sendWeChatNotification(successLogs, 1, 0, 0, null, fileId, sheetId, true, null, null);
|
||||
} catch (Exception e) {
|
||||
log.error("发送微信推送失败", e);
|
||||
// 不影响主流程,继续返回成功
|
||||
@@ -1206,6 +1206,12 @@ public class TencentDocController extends BaseController {
|
||||
// 收集同步成功的详细日志(用于微信推送)
|
||||
List<Map<String, Object>> successLogs = new java.util.ArrayList<>();
|
||||
|
||||
// 收集物流链接更新的记录(用于推送消息说明)
|
||||
List<Map<String, Object>> logisticsLinkUpdatedLogs = new java.util.ArrayList<>();
|
||||
|
||||
// 收集错误详情日志(用于微信推送)
|
||||
List<Map<String, Object>> errorLogs = new java.util.ArrayList<>();
|
||||
|
||||
JSONArray updates = new JSONArray(); // 存储需要更新的行和值
|
||||
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
@@ -1228,34 +1234,79 @@ public class TencentDocController extends BaseController {
|
||||
|
||||
// 检查物流链接列是否已有值(可能是别人手动填写的)
|
||||
String existingLogisticsLink = row.getString(logisticsLinkColumn);
|
||||
if (existingLogisticsLink != null && !existingLogisticsLink.trim().isEmpty()) {
|
||||
// 文档中已有物流链接,同步更新订单的推送状态
|
||||
String trimmedExistingLink = (existingLogisticsLink != null) ? existingLogisticsLink.trim() : "";
|
||||
|
||||
if (!trimmedExistingLink.isEmpty()) {
|
||||
// 文档中已有物流链接,需要比对数据库中的物流链接
|
||||
try {
|
||||
JDOrder existingOrder = jdOrderService.selectJDOrderByThirdPartyOrderNo(orderNo);
|
||||
if (existingOrder != null &&
|
||||
(existingOrder.getTencentDocPushed() == null || existingOrder.getTencentDocPushed() == 0)) {
|
||||
// 订单未标记为已推送,但文档中已有值,同步状态
|
||||
existingOrder.setTencentDocPushed(1);
|
||||
existingOrder.setTencentDocPushTime(new java.util.Date());
|
||||
int syncResult = jdOrderService.updateJDOrder(existingOrder);
|
||||
if (syncResult > 0) {
|
||||
log.info("✓ 同步订单状态成功 - 单号: {}, 行号: {}, 原因: 文档中已有物流链接(可能手动填写)",
|
||||
orderNo, excelRow);
|
||||
} else {
|
||||
log.warn("⚠️ 同步订单状态返回0 - 单号: {}, 行号: {}, 可能未更新",
|
||||
orderNo, excelRow);
|
||||
}
|
||||
if (existingOrder != null) {
|
||||
String dbLogisticsLink = existingOrder.getLogisticsLink();
|
||||
String trimmedDbLink = (dbLogisticsLink != null) ? dbLogisticsLink.trim() : "";
|
||||
|
||||
// 记录同步日志
|
||||
logOperation(batchId, fileId, sheetId, "BATCH_SYNC", orderNo, excelRow, existingLogisticsLink,
|
||||
"SKIPPED", "文档中已有物流链接,已同步订单状态");
|
||||
// 比对物流链接,如果不一致则需要更新
|
||||
if (!trimmedDbLink.isEmpty() && !trimmedExistingLink.equals(trimmedDbLink)) {
|
||||
// 物流链接不一致,需要更新文档中的链接
|
||||
log.info("========== 检测到物流链接不一致 - 单号: {}, 行号: {}, 文档链接: {}, 数据库链接: {}, 开始更新 ==========",
|
||||
orderNo, excelRow, trimmedExistingLink, trimmedDbLink);
|
||||
|
||||
// 添加到更新列表
|
||||
JSONObject update = new JSONObject();
|
||||
update.put("row", excelRow);
|
||||
update.put("column", logisticsLinkColumn);
|
||||
update.put("orderNo", orderNo);
|
||||
update.put("logisticsLink", trimmedDbLink);
|
||||
update.put("isLinkUpdated", true); // 标记为物流链接更新
|
||||
update.put("oldLogisticsLink", trimmedExistingLink); // 旧链接
|
||||
update.put("newLogisticsLink", trimmedDbLink); // 新链接
|
||||
updates.add(update);
|
||||
|
||||
filledCount++; // 统计为填充数量
|
||||
|
||||
// 记录物流链接更新日志
|
||||
Map<String, Object> updatedLog = new java.util.HashMap<>();
|
||||
updatedLog.put("orderNo", orderNo);
|
||||
updatedLog.put("row", excelRow);
|
||||
updatedLog.put("oldLogisticsLink", trimmedExistingLink);
|
||||
updatedLog.put("newLogisticsLink", trimmedDbLink);
|
||||
logisticsLinkUpdatedLogs.add(updatedLog);
|
||||
|
||||
log.info("========== 物流链接不一致,已加入更新队列 - 单号: {}, 行号: {}, 旧链接: {}, 新链接: {} ==========",
|
||||
orderNo, excelRow, trimmedExistingLink, trimmedDbLink);
|
||||
} else {
|
||||
// 物流链接一致或数据库中没有链接,只需同步订单状态
|
||||
if (existingOrder.getTencentDocPushed() == null || existingOrder.getTencentDocPushed() == 0) {
|
||||
// 订单未标记为已推送,但文档中已有值,同步状态
|
||||
existingOrder.setTencentDocPushed(1);
|
||||
existingOrder.setTencentDocPushTime(new java.util.Date());
|
||||
int syncResult = jdOrderService.updateJDOrder(existingOrder);
|
||||
if (syncResult > 0) {
|
||||
log.info("✓ 同步订单状态成功 - 单号: {}, 行号: {}, 原因: 文档中已有物流链接(可能手动填写)",
|
||||
orderNo, excelRow);
|
||||
} else {
|
||||
log.warn("⚠️ 同步订单状态返回0 - 单号: {}, 行号: {}, 可能未更新",
|
||||
orderNo, excelRow);
|
||||
}
|
||||
|
||||
// 记录同步日志
|
||||
logOperation(batchId, fileId, sheetId, "BATCH_SYNC", orderNo, excelRow, trimmedExistingLink,
|
||||
"SKIPPED", "文档中已有物流链接,已同步订单状态");
|
||||
}
|
||||
skippedCount++; // 物流链接一致,跳过写入
|
||||
}
|
||||
} else {
|
||||
// 未找到订单,跳过
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("❌ 同步订单状态失败 - 单号: {}, 行号: {}", orderNo, excelRow, e);
|
||||
log.error("❌ 处理已有物流链接时发生异常 - 单号: {}, 行号: {}", orderNo, excelRow, e);
|
||||
skippedCount++;
|
||||
}
|
||||
|
||||
skippedCount++; // 已有物流链接,跳过写入
|
||||
continue;
|
||||
if (!trimmedExistingLink.isEmpty()) {
|
||||
// 如果已经处理过(无论是更新还是跳过),都继续下一个
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1263,8 +1314,9 @@ public class TencentDocController extends BaseController {
|
||||
JDOrder order = jdOrderService.selectJDOrderByThirdPartyOrderNo(orderNo);
|
||||
|
||||
if (order == null) {
|
||||
errorCount++;
|
||||
log.warn("未找到订单 - 单号: {}, 行号: {}", orderNo, excelRow);
|
||||
// 订单不存在,跳过但不统计为错误
|
||||
skippedCount++;
|
||||
log.info("跳过订单不存在 - 单号: {}, 行号: {}", orderNo, excelRow);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1320,10 +1372,26 @@ public class TencentDocController extends BaseController {
|
||||
} else {
|
||||
errorCount++;
|
||||
log.warn("订单物流链接为空 - 单号: {}, 行号: {}", orderNo, excelRow);
|
||||
|
||||
// 记录错误详情
|
||||
Map<String, Object> errorLog = new java.util.HashMap<>();
|
||||
errorLog.put("orderNo", orderNo);
|
||||
errorLog.put("row", excelRow);
|
||||
errorLog.put("errorType", "物流链接为空");
|
||||
errorLog.put("errorMessage", "订单物流链接为空");
|
||||
errorLogs.add(errorLog);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
errorCount++;
|
||||
log.error("处理订单失败 - 单号: {}, 行号: {}", orderNo, excelRow, e);
|
||||
|
||||
// 记录错误详情
|
||||
Map<String, Object> errorLog = new java.util.HashMap<>();
|
||||
errorLog.put("orderNo", orderNo);
|
||||
errorLog.put("row", excelRow);
|
||||
errorLog.put("errorType", "处理订单异常");
|
||||
errorLog.put("errorMessage", e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
|
||||
errorLogs.add(errorLog);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,6 +1427,15 @@ public class TencentDocController extends BaseController {
|
||||
if (verifyData == null || !verifyData.containsKey("values")) {
|
||||
log.warn("验证失败 - 无法读取行 {}", row);
|
||||
errorCount++;
|
||||
|
||||
// 记录错误详情
|
||||
Map<String, Object> errorLog = new java.util.HashMap<>();
|
||||
errorLog.put("orderNo", expectedOrderNo);
|
||||
errorLog.put("row", row);
|
||||
errorLog.put("errorType", "验证失败");
|
||||
errorLog.put("errorMessage", "无法读取行数据");
|
||||
errorLogs.add(errorLog);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1366,6 +1443,15 @@ public class TencentDocController extends BaseController {
|
||||
if (verifyRows == null || verifyRows.isEmpty()) {
|
||||
log.warn("验证失败 - 行 {} 数据为空", row);
|
||||
errorCount++;
|
||||
|
||||
// 记录错误详情
|
||||
Map<String, Object> errorLog = new java.util.HashMap<>();
|
||||
errorLog.put("orderNo", expectedOrderNo);
|
||||
errorLog.put("row", row);
|
||||
errorLog.put("errorType", "验证失败");
|
||||
errorLog.put("errorMessage", "行数据为空");
|
||||
errorLogs.add(errorLog);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1373,6 +1459,15 @@ public class TencentDocController extends BaseController {
|
||||
if (verifyRow == null || verifyRow.size() <= Math.max(orderNoColumn, logisticsLinkColumn)) {
|
||||
log.warn("验证失败 - 行 {} 列数不足", row);
|
||||
errorCount++;
|
||||
|
||||
// 记录错误详情
|
||||
Map<String, Object> errorLog = new java.util.HashMap<>();
|
||||
errorLog.put("orderNo", expectedOrderNo);
|
||||
errorLog.put("row", row);
|
||||
errorLog.put("errorType", "验证失败");
|
||||
errorLog.put("errorMessage", "行列数不足");
|
||||
errorLogs.add(errorLog);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1381,21 +1476,46 @@ public class TencentDocController extends BaseController {
|
||||
if (currentOrderNo == null || !currentOrderNo.trim().equals(expectedOrderNo)) {
|
||||
log.warn("验证失败 - 行 {} 单号不匹配,预期: {}, 实际: {}", row, expectedOrderNo, currentOrderNo);
|
||||
errorCount++;
|
||||
|
||||
// 记录错误详情
|
||||
Map<String, Object> errorLog = new java.util.HashMap<>();
|
||||
errorLog.put("orderNo", expectedOrderNo);
|
||||
errorLog.put("row", row);
|
||||
errorLog.put("errorType", "验证失败");
|
||||
errorLog.put("errorMessage", String.format("单号不匹配,预期: %s, 实际: %s", expectedOrderNo, currentOrderNo));
|
||||
errorLogs.add(errorLog);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证2:物流链接列是否为空
|
||||
// 验证2:物流链接列是否为空(如果是物流链接更新,则允许覆盖)
|
||||
Boolean isLinkUpdated = update.getBoolean("isLinkUpdated");
|
||||
String currentLogisticsLink = verifyRow.getString(logisticsLinkColumn);
|
||||
if (currentLogisticsLink != null && !currentLogisticsLink.trim().isEmpty()) {
|
||||
if (!Boolean.TRUE.equals(isLinkUpdated) && currentLogisticsLink != null && !currentLogisticsLink.trim().isEmpty()) {
|
||||
log.info("跳过写入 - 行 {} 单号 {} 的物流链接列已有值: {}", row, expectedOrderNo, currentLogisticsLink);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果是物流链接更新,记录日志
|
||||
if (Boolean.TRUE.equals(isLinkUpdated)) {
|
||||
String oldLink = update.getString("oldLogisticsLink");
|
||||
String newLink = update.getString("newLogisticsLink");
|
||||
log.info("========== 开始更新物流链接 - 行: {}, 单号: {}, 旧链接: {}, 新链接: {} ==========",
|
||||
row, expectedOrderNo, oldLink, newLink);
|
||||
}
|
||||
|
||||
// 验证通过,执行写入
|
||||
// 使用 batchUpdate 一次性更新多个字段
|
||||
JSONArray requests = new JSONArray();
|
||||
|
||||
// 如果是物流链接更新,先清除单元格内容,然后再写入新内容
|
||||
if (Boolean.TRUE.equals(isLinkUpdated)) {
|
||||
// 先清除单元格内容(写入空文本)
|
||||
requests.add(buildUpdateCellRequest(sheetId, row - 1, logisticsLinkColumn, "", false));
|
||||
log.info("✓ 先清除单元格内容 - 行: {}, 列: {}", row, logisticsLinkColumn);
|
||||
}
|
||||
|
||||
// 1. 更新物流单号(超链接类型)
|
||||
requests.add(buildUpdateCellRequest(sheetId, row - 1, logisticsLinkColumn, logisticsLink, true));
|
||||
|
||||
@@ -1419,9 +1539,12 @@ public class TencentDocController extends BaseController {
|
||||
log.info("✓ 准备写入是否安排 - 单号: {}, 是否安排: 2, 行: {}, 列: {}", expectedOrderNo, row, arrangedColumn);
|
||||
}
|
||||
|
||||
// 5. 更新"标记"列(如果存在)
|
||||
if (markColumn != null) {
|
||||
// 5. 更新"标记"列(如果存在,且不是物流链接更新时才写入,物流链接更新时保留原日期)
|
||||
if (markColumn != null && !Boolean.TRUE.equals(isLinkUpdated)) {
|
||||
requests.add(buildUpdateCellRequest(sheetId, row - 1, markColumn, today, false));
|
||||
log.info("✓ 准备写入标记日期 - 单号: {}, 日期: {}, 行: {}, 列: {}", expectedOrderNo, today, row, markColumn);
|
||||
} else if (markColumn != null && Boolean.TRUE.equals(isLinkUpdated)) {
|
||||
log.info("✓ 物流链接更新,保留原标记日期 - 单号: {}, 行: {}", expectedOrderNo, row);
|
||||
}
|
||||
|
||||
// 构建完整的 batchUpdate 请求体
|
||||
@@ -1474,6 +1597,18 @@ public class TencentDocController extends BaseController {
|
||||
if (phone != null) {
|
||||
successLog.put("phone", phone);
|
||||
}
|
||||
|
||||
// 检查是否为物流链接更新(复用之前的变量)
|
||||
if (Boolean.TRUE.equals(isLinkUpdated)) {
|
||||
String oldLink = update.getString("oldLogisticsLink");
|
||||
String newLink = update.getString("newLogisticsLink");
|
||||
successLog.put("isLinkUpdated", true);
|
||||
successLog.put("oldLogisticsLink", oldLink);
|
||||
successLog.put("newLogisticsLink", newLink);
|
||||
log.info("✓ 记录物流链接更新到成功日志 - 单号: {}, 旧链接: {}, 新链接: {}",
|
||||
expectedOrderNo, oldLink, newLink);
|
||||
}
|
||||
|
||||
successLogs.add(successLog);
|
||||
} catch (Exception e) {
|
||||
log.error("写入数据失败 - 行: {}", entry.getKey(), e);
|
||||
@@ -1484,6 +1619,14 @@ public class TencentDocController extends BaseController {
|
||||
String logisticsLink = entry.getValue().getString("logisticsLink");
|
||||
logOperation(batchId, fileId, sheetId, "BATCH_SYNC", orderNo, entry.getKey(), logisticsLink,
|
||||
"FAILED", "写入异常: " + e.getMessage());
|
||||
|
||||
// 记录错误详情
|
||||
Map<String, Object> errorLog = new java.util.HashMap<>();
|
||||
errorLog.put("orderNo", orderNo);
|
||||
errorLog.put("row", entry.getKey());
|
||||
errorLog.put("errorType", "写入失败");
|
||||
errorLog.put("errorMessage", e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
|
||||
errorLogs.add(errorLog);
|
||||
}
|
||||
|
||||
// 添加延迟,避免API调用频率过高
|
||||
@@ -1598,11 +1741,11 @@ public class TencentDocController extends BaseController {
|
||||
log.warn("⚠️ batchId 为空,无法更新批量推送记录");
|
||||
}
|
||||
|
||||
// 如果有成功记录,发送微信推送
|
||||
if (!successLogs.isEmpty()) {
|
||||
// 如果有成功记录或错误记录,发送微信推送
|
||||
if (!successLogs.isEmpty() || !errorLogs.isEmpty()) {
|
||||
try {
|
||||
// 传递batchId给推送方法,用于日志关联
|
||||
sendWeChatNotificationWithBatchId(successLogs, filledCount, skippedCount, errorCount, batchId, fileId, sheetId, false);
|
||||
// 传递batchId、物流链接更新日志和错误日志给推送方法,用于日志关联和说明
|
||||
sendWeChatNotificationWithBatchId(successLogs, filledCount, skippedCount, errorCount, batchId, fileId, sheetId, false, logisticsLinkUpdatedLogs, errorLogs);
|
||||
} catch (Exception e) {
|
||||
log.error("发送微信推送失败", e);
|
||||
// 不影响主流程,继续返回成功
|
||||
@@ -2105,6 +2248,437 @@ public class TencentDocController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从腾讯文档拉取物流信息并对比更新本地订单
|
||||
* 读取腾讯文档中的单号和物流链接,与本地订单进行对比,如果不一样则覆盖写入本地
|
||||
*
|
||||
* @param params 包含 fileId, sheetId, startRow(起始行,默认850), endRow(结束行,默认2500)
|
||||
* @return 同步结果
|
||||
*/
|
||||
@PostMapping("/syncLogisticsFromTencentDoc")
|
||||
public AjaxResult syncLogisticsFromTencentDoc(@RequestBody Map<String, Object> params) {
|
||||
String batchId = java.util.UUID.randomUUID().toString().replace("-", "");
|
||||
|
||||
try {
|
||||
// 获取访问令牌
|
||||
String accessToken;
|
||||
try {
|
||||
accessToken = tencentDocTokenService.refreshAccessToken();
|
||||
log.info("成功刷新访问令牌");
|
||||
} catch (Exception e) {
|
||||
log.error("刷新访问令牌失败", e);
|
||||
try {
|
||||
accessToken = tencentDocTokenService.getValidAccessToken();
|
||||
} catch (Exception e2) {
|
||||
return AjaxResult.error("访问令牌无效,请先完成授权。获取授权URL: GET /jarvis/tendoc/authUrl");
|
||||
}
|
||||
}
|
||||
|
||||
// 从参数或配置中获取文档信息
|
||||
String fileId = (String) params.get("fileId");
|
||||
String sheetId = (String) params.get("sheetId");
|
||||
|
||||
final String CONFIG_KEY_PREFIX = "tencent:doc:auto:config:";
|
||||
if (fileId == null || fileId.isEmpty()) {
|
||||
fileId = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "fileId");
|
||||
if (fileId == null || fileId.isEmpty()) {
|
||||
fileId = tencentDocConfig.getFileId();
|
||||
}
|
||||
}
|
||||
if (sheetId == null || sheetId.isEmpty()) {
|
||||
sheetId = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "sheetId");
|
||||
if (sheetId == null || sheetId.isEmpty()) {
|
||||
sheetId = tencentDocConfig.getSheetId();
|
||||
}
|
||||
}
|
||||
|
||||
// 从配置中读取表头行
|
||||
Integer headerRow = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "headerRow");
|
||||
if (headerRow == null) {
|
||||
headerRow = tencentDocConfig.getHeaderRow();
|
||||
}
|
||||
|
||||
// 起始行,默认850
|
||||
Integer startRow = params.get("startRow") != null ?
|
||||
Integer.valueOf(params.get("startRow").toString()) : 850;
|
||||
|
||||
// 结束行,默认到2500行
|
||||
Integer endRow = params.get("endRow") != null ?
|
||||
Integer.valueOf(params.get("endRow").toString()) : 2500;
|
||||
|
||||
if (accessToken == null || fileId == null || sheetId == null) {
|
||||
return AjaxResult.error("文档配置不完整,请先配置 fileId 和 sheetId");
|
||||
}
|
||||
|
||||
log.info("从腾讯文档同步物流信息开始 - fileId: {}, sheetId: {}, 起始行: {}, 结束行: {}",
|
||||
fileId, sheetId, startRow, endRow);
|
||||
|
||||
// 读取表头,识别列位置
|
||||
String headerRange = String.format("A%d:Z%d", headerRow, headerRow);
|
||||
JSONObject headerData = tencentDocService.readSheetData(accessToken, fileId, sheetId, headerRange);
|
||||
|
||||
if (headerData == null || !headerData.containsKey("values")) {
|
||||
return AjaxResult.error("读取表头失败");
|
||||
}
|
||||
|
||||
JSONArray headerValues = headerData.getJSONArray("values");
|
||||
if (headerValues == null || headerValues.isEmpty()) {
|
||||
return AjaxResult.error("表头数据为空");
|
||||
}
|
||||
|
||||
JSONArray headerRowData = headerValues.getJSONArray(0);
|
||||
if (headerRowData == null || headerRowData.isEmpty()) {
|
||||
return AjaxResult.error("无法识别表头");
|
||||
}
|
||||
|
||||
// 识别列位置
|
||||
Integer orderNoColumn = null; // "单号"列
|
||||
Integer logisticsLinkColumn = null; // "物流单号"或"物流链接"列
|
||||
|
||||
for (int i = 0; i < headerRowData.size(); i++) {
|
||||
String cellValue = headerRowData.getString(i);
|
||||
if (cellValue != null) {
|
||||
String cellValueTrim = cellValue.trim();
|
||||
|
||||
if (orderNoColumn == null && cellValueTrim.contains("单号") && !cellValueTrim.contains("物流")) {
|
||||
orderNoColumn = i;
|
||||
log.info("✓ 识别到 '单号' 列:第 {} 列(索引{})", i + 1, i);
|
||||
}
|
||||
|
||||
if (logisticsLinkColumn == null && (cellValueTrim.contains("物流单号") || cellValueTrim.contains("物流链接"))) {
|
||||
logisticsLinkColumn = i;
|
||||
log.info("✓ 识别到 '物流单号' 列:第 {} 列(索引{})", i + 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (orderNoColumn == null || logisticsLinkColumn == null) {
|
||||
return AjaxResult.error("无法识别表头列,请确保表头包含'单号'和'物流单号'列");
|
||||
}
|
||||
|
||||
// 统计结果
|
||||
int updatedCount = 0; // 更新数量
|
||||
int skippedCount = 0; // 跳过数量(物流链接相同或为空)
|
||||
int errorCount = 0; // 错误数量
|
||||
java.util.List<String> updatedOrderNos = new java.util.ArrayList<>(); // 更新的单号列表
|
||||
|
||||
// 分批读取数据,每批200行(避免单次读取过多数据导致API限制)
|
||||
final int BATCH_SIZE = 200;
|
||||
int currentStartRow = startRow;
|
||||
int totalBatches = (int) Math.ceil((double)(endRow - startRow + 1) / BATCH_SIZE);
|
||||
int currentBatch = 0;
|
||||
|
||||
log.info("开始分批处理,共 {} 批,每批 {} 行", totalBatches, BATCH_SIZE);
|
||||
|
||||
while (currentStartRow <= endRow) {
|
||||
currentBatch++;
|
||||
int currentEndRow = Math.min(currentStartRow + BATCH_SIZE - 1, endRow);
|
||||
|
||||
log.info("正在处理第 {}/{} 批:第 {} 行到第 {} 行", currentBatch, totalBatches, currentStartRow, currentEndRow);
|
||||
|
||||
// 读取当前批次的数据行
|
||||
String dataRange = String.format("A%d:Z%d", currentStartRow, currentEndRow);
|
||||
log.info("读取数据范围: {}", dataRange);
|
||||
|
||||
JSONObject dataResponse = null;
|
||||
try {
|
||||
dataResponse = tencentDocService.readSheetData(accessToken, fileId, sheetId, dataRange);
|
||||
} catch (Exception e) {
|
||||
log.error("读取第 {} 批数据失败({} - {} 行)", currentBatch, currentStartRow, currentEndRow, e);
|
||||
errorCount += (currentEndRow - currentStartRow + 1);
|
||||
// 继续处理下一批
|
||||
currentStartRow = currentEndRow + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dataResponse == null || !dataResponse.containsKey("values")) {
|
||||
log.warn("第 {} 批数据读取返回空({} - {} 行),跳过", currentBatch, currentStartRow, currentEndRow);
|
||||
currentStartRow = currentEndRow + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
JSONArray rows = dataResponse.getJSONArray("values");
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
log.info("第 {} 批数据为空({} - {} 行),跳过", currentBatch, currentStartRow, currentEndRow);
|
||||
currentStartRow = currentEndRow + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info("第 {} 批读取到 {} 行数据", currentBatch, rows.size());
|
||||
|
||||
// 处理当前批次的每一行
|
||||
for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
|
||||
JSONArray row = rows.getJSONArray(rowIndex);
|
||||
if (row == null || row.size() <= Math.max(orderNoColumn, logisticsLinkColumn)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int actualRow = currentStartRow + rowIndex;
|
||||
// 确保不超过结束行
|
||||
if (actualRow > endRow) {
|
||||
break;
|
||||
}
|
||||
|
||||
String orderNoFromDoc = row.getString(orderNoColumn);
|
||||
String logisticsLinkFromDoc = row.getString(logisticsLinkColumn);
|
||||
|
||||
// 跳过单号为空的行
|
||||
if (orderNoFromDoc == null || orderNoFromDoc.trim().isEmpty()) {
|
||||
log.debug("跳过第 {} 行:单号为空", actualRow);
|
||||
skippedCount++;
|
||||
logOperation(batchId, fileId, sheetId, "SYNC_LOGISTICS", null, actualRow, null,
|
||||
"SKIPPED", "单号为空");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过物流链接为空的行
|
||||
if (logisticsLinkFromDoc == null || logisticsLinkFromDoc.trim().isEmpty()) {
|
||||
log.debug("跳过第 {} 行:物流链接为空", actualRow);
|
||||
skippedCount++;
|
||||
logOperation(batchId, fileId, sheetId, "SYNC_LOGISTICS", orderNoFromDoc, actualRow, null,
|
||||
"SKIPPED", "物流链接为空");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 清理物流链接(去除空格、换行符、中文等)
|
||||
String cleanedLogisticsLink = cleanLogisticsLink(logisticsLinkFromDoc);
|
||||
|
||||
try {
|
||||
// 通过第三方单号查找本地订单
|
||||
JDOrder order = jdOrderService.selectJDOrderByThirdPartyOrderNo(orderNoFromDoc.trim());
|
||||
|
||||
if (order == null) {
|
||||
// 如果通过第三方单号找不到,尝试通过内部单号(remark)查找
|
||||
order = jdOrderService.selectJDOrderByRemark(orderNoFromDoc.trim());
|
||||
}
|
||||
|
||||
if (order == null) {
|
||||
log.warn("未找到匹配的订单 - 行: {}, 单号: {}", actualRow, orderNoFromDoc);
|
||||
errorCount++;
|
||||
logOperation(batchId, fileId, sheetId, "SYNC_LOGISTICS", orderNoFromDoc, actualRow, cleanedLogisticsLink,
|
||||
"FAILED", "未找到匹配的订单");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 对比物流链接
|
||||
String localLogisticsLink = order.getLogisticsLink();
|
||||
if (localLogisticsLink != null) {
|
||||
localLogisticsLink = localLogisticsLink.trim();
|
||||
}
|
||||
|
||||
// 如果本地物流链接为空,直接更新
|
||||
if (localLogisticsLink == null || localLogisticsLink.isEmpty()) {
|
||||
// 本地物流链接为空,直接更新
|
||||
order.setLogisticsLink(cleanedLogisticsLink);
|
||||
int updateResult = jdOrderService.updateJDOrder(order);
|
||||
|
||||
if (updateResult <= 0) {
|
||||
log.error("更新订单失败 - 行: {}, 订单ID: {}, 单号: {}",
|
||||
actualRow, order.getId(), order.getRemark());
|
||||
errorCount++;
|
||||
logOperation(batchId, fileId, sheetId, "SYNC_LOGISTICS", order.getRemark(), actualRow, cleanedLogisticsLink,
|
||||
"FAILED", "更新订单失败");
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info("✓ 更新订单物流链接成功(本地为空) - 行: {}, 订单: {}, 新物流: {}",
|
||||
actualRow, order.getRemark(), cleanedLogisticsLink);
|
||||
|
||||
updatedCount++;
|
||||
updatedOrderNos.add(order.getRemark() != null ? order.getRemark() : orderNoFromDoc);
|
||||
|
||||
// 记录成功日志
|
||||
logOperation(batchId, fileId, sheetId, "SYNC_LOGISTICS", order.getRemark(), actualRow, cleanedLogisticsLink,
|
||||
"SUCCESS", String.format("已更新物流链接:空 -> %s", cleanedLogisticsLink));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果物流链接相同,跳过
|
||||
if (cleanedLogisticsLink.equals(localLogisticsLink)) {
|
||||
log.debug("跳过第 {} 行:物流链接相同 - 单号: {}, 物流: {}", actualRow, orderNoFromDoc, cleanedLogisticsLink);
|
||||
skippedCount++;
|
||||
logOperation(batchId, fileId, sheetId, "SYNC_LOGISTICS", order.getRemark(), actualRow, cleanedLogisticsLink,
|
||||
"SKIPPED", "物流链接相同");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 物流链接不同,更新本地订单
|
||||
String oldLogisticsLink = localLogisticsLink != null ? localLogisticsLink : "空";
|
||||
order.setLogisticsLink(cleanedLogisticsLink);
|
||||
int updateResult = jdOrderService.updateJDOrder(order);
|
||||
|
||||
if (updateResult <= 0) {
|
||||
log.error("更新订单失败 - 行: {}, 订单ID: {}, 单号: {}",
|
||||
actualRow, order.getId(), order.getRemark());
|
||||
errorCount++;
|
||||
logOperation(batchId, fileId, sheetId, "SYNC_LOGISTICS", order.getRemark(), actualRow, cleanedLogisticsLink,
|
||||
"FAILED", "更新订单失败");
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info("✓ 更新订单物流链接成功 - 行: {}, 订单: {}, 旧物流: {}, 新物流: {}",
|
||||
actualRow, order.getRemark(), oldLogisticsLink, cleanedLogisticsLink);
|
||||
|
||||
updatedCount++;
|
||||
updatedOrderNos.add(order.getRemark() != null ? order.getRemark() : orderNoFromDoc);
|
||||
|
||||
// 记录成功日志
|
||||
logOperation(batchId, fileId, sheetId, "SYNC_LOGISTICS", order.getRemark(), actualRow, cleanedLogisticsLink,
|
||||
"SUCCESS", String.format("已更新物流链接:%s -> %s", oldLogisticsLink, cleanedLogisticsLink));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理第 {} 行失败", actualRow, e);
|
||||
errorCount++;
|
||||
logOperation(batchId, fileId, sheetId, "SYNC_LOGISTICS", orderNoFromDoc, actualRow, cleanedLogisticsLink,
|
||||
"FAILED", "处理异常: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 添加延迟,避免API调用频率过高
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
log.info("第 {}/{} 批处理完成,当前统计 - 更新: {}, 跳过: {}, 错误: {}",
|
||||
currentBatch, totalBatches, updatedCount, skippedCount, errorCount);
|
||||
|
||||
// 移动到下一批
|
||||
currentStartRow = currentEndRow + 1;
|
||||
|
||||
// 批次之间的延迟,避免API调用频率过高
|
||||
if (currentStartRow <= endRow) {
|
||||
try {
|
||||
Thread.sleep(200); // 批次之间延迟200ms
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("batchId", batchId);
|
||||
result.put("startRow", startRow);
|
||||
result.put("endRow", endRow);
|
||||
result.put("updatedCount", updatedCount);
|
||||
result.put("skippedCount", skippedCount);
|
||||
result.put("errorCount", errorCount);
|
||||
result.put("updatedOrderNos", updatedOrderNos);
|
||||
|
||||
String message = String.format(
|
||||
"✓ 物流信息同步完成:更新 %d 条,跳过 %d 条,错误 %d 条\n" +
|
||||
" 处理范围:第 %d-%d 行\n" +
|
||||
" 批次ID:%s",
|
||||
updatedCount, skippedCount, errorCount, startRow, endRow, batchId);
|
||||
result.put("message", message);
|
||||
|
||||
log.info("从腾讯文档同步物流信息完成 - {}", message);
|
||||
|
||||
// 如果有更新的订单,发送微信推送通知
|
||||
if (updatedCount > 0) {
|
||||
try {
|
||||
sendLogisticsSyncNotification(updatedOrderNos, updatedCount, skippedCount, errorCount, batchId, fileId, sheetId);
|
||||
} catch (Exception e) {
|
||||
log.error("发送微信推送通知失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
return AjaxResult.success("物流信息同步完成", result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("从腾讯文档同步物流信息失败", e);
|
||||
return AjaxResult.error("同步失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送物流信息同步的微信推送通知
|
||||
*
|
||||
* @param updatedOrderNos 更新的单号列表
|
||||
* @param updatedCount 更新数量
|
||||
* @param skippedCount 跳过数量
|
||||
* @param errorCount 错误数量
|
||||
* @param batchId 批次ID
|
||||
* @param fileId 文档ID
|
||||
* @param sheetId 工作表ID
|
||||
*/
|
||||
private void sendLogisticsSyncNotification(java.util.List<String> updatedOrderNos, int updatedCount, int skippedCount, int errorCount, String batchId, String fileId, String sheetId) {
|
||||
try {
|
||||
log.info("========== 开始发送物流信息同步微信推送通知 ==========");
|
||||
log.info("更新: {} 条, 跳过: {} 条, 错误: {} 条", updatedCount, skippedCount, errorCount);
|
||||
log.info("批次ID: {}, 文档ID: {}, 工作表ID: {}", batchId, fileId, sheetId);
|
||||
|
||||
// 微信推送服务配置
|
||||
String wxSendBaseUrl = "https://wxts.van333.cn";
|
||||
String pushToken = "super_token_b62190c26";
|
||||
String pushUrl = wxSendBaseUrl + "/wx/send/ty";
|
||||
|
||||
// 构建推送内容
|
||||
StringBuilder content = new StringBuilder();
|
||||
content.append("【腾讯文档物流信息同步成功】\n\n");
|
||||
content.append(String.format("✓ 更新数量: %d 条\n", updatedCount));
|
||||
if (skippedCount > 0) {
|
||||
content.append(String.format("⊘ 跳过: %d 条\n", skippedCount));
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
content.append(String.format("✗ 错误: %d 条\n", errorCount));
|
||||
}
|
||||
content.append("\n");
|
||||
|
||||
if (!updatedOrderNos.isEmpty()) {
|
||||
content.append("【更新的单号列表】\n");
|
||||
// 最多显示30条单号,避免消息过长
|
||||
int maxDisplay = Math.min(30, updatedOrderNos.size());
|
||||
for (int i = 0; i < maxDisplay; i++) {
|
||||
content.append(String.format("%d. %s\n", i + 1, updatedOrderNos.get(i)));
|
||||
}
|
||||
|
||||
if (updatedOrderNos.size() > maxDisplay) {
|
||||
content.append(String.format("\n... 还有 %d 个单号未显示", updatedOrderNos.size() - maxDisplay));
|
||||
}
|
||||
}
|
||||
|
||||
// 构建请求体
|
||||
JSONObject requestBody = new JSONObject();
|
||||
requestBody.put("title", "腾讯文档物流信息同步成功");
|
||||
requestBody.put("text", content.toString());
|
||||
requestBody.put("vanToken", pushToken);
|
||||
requestBody.put("messageType", "TY");
|
||||
requestBody.put("touser", "LinPingFan,Hong");
|
||||
|
||||
String jsonBody = requestBody.toJSONString();
|
||||
|
||||
log.info("微信推送请求 - URL: {}", pushUrl);
|
||||
log.info("微信推送请求体: {}", jsonBody);
|
||||
|
||||
// 发送POST请求
|
||||
String result = sendPostRequest(pushUrl, jsonBody, pushToken);
|
||||
|
||||
log.info("========== 微信推送发送完成 ==========");
|
||||
log.info("推送URL: {}", pushUrl);
|
||||
log.info("推送响应: {}", result);
|
||||
|
||||
// 记录微信推送操作日志
|
||||
String pushLogMessage = String.format("物流信息同步微信推送成功 - 更新: %d条, 跳过: %d条, 错误: %d条",
|
||||
updatedCount, skippedCount, errorCount);
|
||||
logOperation(batchId, fileId, sheetId, "WECHAT_PUSH", null, null, null,
|
||||
"SUCCESS", pushLogMessage);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("========== 发送物流信息同步微信推送失败 ==========", e);
|
||||
|
||||
// 记录微信推送失败日志
|
||||
String pushLogMessage = String.format("物流信息同步微信推送失败 - 错误: %s", e.getMessage());
|
||||
logOperation(batchId, fileId, sheetId, "WECHAT_PUSH", null, null, null,
|
||||
"FAILED", pushLogMessage);
|
||||
|
||||
// 不抛出异常,避免影响主流程
|
||||
log.warn("微信推送失败,但不影响主流程,继续执行");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送微信推送通知(同步成功日志)
|
||||
*
|
||||
@@ -2128,9 +2702,11 @@ public class TencentDocController extends BaseController {
|
||||
* @param fileId 文档ID
|
||||
* @param sheetId 工作表ID
|
||||
* @param isManual 是否为手动执行
|
||||
* @param logisticsLinkUpdatedLogs 物流链接更新日志列表(可为null)
|
||||
* @param errorLogs 错误日志列表(可为null)
|
||||
*/
|
||||
private void sendWeChatNotificationWithBatchId(List<Map<String, Object>> successLogs, int filledCount, int skippedCount, int errorCount, String batchId, String fileId, String sheetId, boolean isManual) {
|
||||
sendWeChatNotification(successLogs, filledCount, skippedCount, errorCount, batchId, fileId, sheetId, isManual);
|
||||
private void sendWeChatNotificationWithBatchId(List<Map<String, Object>> successLogs, int filledCount, int skippedCount, int errorCount, String batchId, String fileId, String sheetId, boolean isManual, List<Map<String, Object>> logisticsLinkUpdatedLogs, List<Map<String, Object>> errorLogs) {
|
||||
sendWeChatNotification(successLogs, filledCount, skippedCount, errorCount, batchId, fileId, sheetId, isManual, logisticsLinkUpdatedLogs, errorLogs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2153,7 +2729,7 @@ public class TencentDocController extends BaseController {
|
||||
if (sheetId == null || sheetId.isEmpty()) {
|
||||
sheetId = tencentDocConfig.getSheetId();
|
||||
}
|
||||
sendWeChatNotification(successLogs, filledCount, skippedCount, errorCount, null, fileId, sheetId, isManual);
|
||||
sendWeChatNotification(successLogs, filledCount, skippedCount, errorCount, null, fileId, sheetId, isManual, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2167,8 +2743,10 @@ public class TencentDocController extends BaseController {
|
||||
* @param fileId 文档ID
|
||||
* @param sheetId 工作表ID
|
||||
* @param isManual 是否为手动执行
|
||||
* @param logisticsLinkUpdatedLogs 物流链接更新日志列表(可为null)
|
||||
* @param errorLogs 错误日志列表(可为null)
|
||||
*/
|
||||
private void sendWeChatNotification(List<Map<String, Object>> successLogs, int filledCount, int skippedCount, int errorCount, String batchId, String fileId, String sheetId, boolean isManual) {
|
||||
private void sendWeChatNotification(List<Map<String, Object>> successLogs, int filledCount, int skippedCount, int errorCount, String batchId, String fileId, String sheetId, boolean isManual, List<Map<String, Object>> logisticsLinkUpdatedLogs, List<Map<String, Object>> errorLogs) {
|
||||
try {
|
||||
log.info("========== 开始发送微信推送通知 ==========");
|
||||
log.info("推送类型: {}, 成功: {} 条, 跳过: {} 条, 错误: {} 条",
|
||||
@@ -2178,7 +2756,7 @@ public class TencentDocController extends BaseController {
|
||||
// 微信推送服务配置
|
||||
String wxSendBaseUrl = "https://wxts.van333.cn";
|
||||
String pushToken = "super_token_b62190c26";
|
||||
String pushUrl = wxSendBaseUrl + "/dc/send/ty";
|
||||
String pushUrl = wxSendBaseUrl + "/wx/send/ty";
|
||||
|
||||
// 构建推送内容
|
||||
StringBuilder content = new StringBuilder();
|
||||
@@ -2194,6 +2772,14 @@ public class TencentDocController extends BaseController {
|
||||
if (errorCount > 0) {
|
||||
content.append(String.format("✗ 错误: %d 条\n", errorCount));
|
||||
}
|
||||
|
||||
// 如果有物流链接更新,添加说明
|
||||
int logisticsLinkUpdatedCount = 0;
|
||||
if (logisticsLinkUpdatedLogs != null && !logisticsLinkUpdatedLogs.isEmpty()) {
|
||||
logisticsLinkUpdatedCount = logisticsLinkUpdatedLogs.size();
|
||||
content.append(String.format("【物流链接已更新】: %d 条\n", logisticsLinkUpdatedCount));
|
||||
}
|
||||
|
||||
content.append("\n");
|
||||
|
||||
if (!successLogs.isEmpty()) {
|
||||
@@ -2212,7 +2798,21 @@ public class TencentDocController extends BaseController {
|
||||
if (phone != null && !phone.isEmpty()) {
|
||||
content.append(String.format(" 电话: %s\n", phone));
|
||||
}
|
||||
content.append(String.format(" 物流: %s\n", logisticsLink));
|
||||
|
||||
// 检查是否为物流链接更新
|
||||
Boolean isLinkUpdated = (Boolean) log.get("isLinkUpdated");
|
||||
if (Boolean.TRUE.equals(isLinkUpdated)) {
|
||||
String oldLink = (String) log.get("oldLogisticsLink");
|
||||
String newLink = (String) log.get("newLogisticsLink");
|
||||
content.append(String.format(" 【物流链接已更新】\n"));
|
||||
content.append(String.format(" 新链接: %s\n", newLink));
|
||||
if (oldLink != null && !oldLink.isEmpty()) {
|
||||
content.append(String.format(" 旧链接: %s\n", oldLink));
|
||||
}
|
||||
} else {
|
||||
content.append(String.format(" 物流: %s\n", logisticsLink));
|
||||
}
|
||||
|
||||
if (i < maxDisplay - 1) {
|
||||
content.append("\n");
|
||||
}
|
||||
@@ -2223,12 +2823,45 @@ public class TencentDocController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有错误记录,添加错误详情
|
||||
if (errorLogs != null && !errorLogs.isEmpty()) {
|
||||
content.append("\n【错误详情】\n");
|
||||
// 最多显示20条错误记录,避免消息过长
|
||||
int maxErrorDisplay = Math.min(20, errorLogs.size());
|
||||
for (int i = 0; i < maxErrorDisplay; i++) {
|
||||
Map<String, Object> errorLog = errorLogs.get(i);
|
||||
String orderNo = (String) errorLog.get("orderNo");
|
||||
Integer row = (Integer) errorLog.get("row");
|
||||
String errorType = (String) errorLog.get("errorType");
|
||||
String errorMessage = (String) errorLog.get("errorMessage");
|
||||
|
||||
content.append(String.format("%d. 单号: %s\n", i + 1, orderNo != null ? orderNo : "未知"));
|
||||
if (row != null) {
|
||||
content.append(String.format(" 行号: %d\n", row));
|
||||
}
|
||||
if (errorType != null && !errorType.isEmpty()) {
|
||||
content.append(String.format(" 错误类型: %s\n", errorType));
|
||||
}
|
||||
if (errorMessage != null && !errorMessage.isEmpty()) {
|
||||
content.append(String.format(" 错误信息: %s\n", errorMessage));
|
||||
}
|
||||
if (i < maxErrorDisplay - 1) {
|
||||
content.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (errorLogs.size() > maxErrorDisplay) {
|
||||
content.append(String.format("\n... 还有 %d 条错误记录未显示", errorLogs.size() - maxErrorDisplay));
|
||||
}
|
||||
}
|
||||
|
||||
// 构建请求体
|
||||
JSONObject requestBody = new JSONObject();
|
||||
requestBody.put("title", "腾讯文档同步成功");
|
||||
requestBody.put("text", content.toString());
|
||||
requestBody.put("vanToken", pushToken);
|
||||
requestBody.put("messageType", "TY");
|
||||
requestBody.put("touser","LinPingFan,Hong");
|
||||
// touser 可以为空,使用默认接收人
|
||||
|
||||
String jsonBody = requestBody.toJSONString();
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package com.ruoyi.web.controller.jarvis;
|
||||
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo;
|
||||
import com.ruoyi.jarvis.service.IWPS365OAuthService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* WPS365 OAuth回调控制器
|
||||
* 用于处理OAuth回调,避免前端路由拦截
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/wps365-callback")
|
||||
public class WPS365CallbackController extends BaseController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WPS365CallbackController.class);
|
||||
|
||||
@Autowired
|
||||
private IWPS365OAuthService wps365OAuthService;
|
||||
|
||||
/**
|
||||
* OAuth回调 - 通过授权码获取访问令牌(GET请求)
|
||||
* 路径:/wps365-callback
|
||||
* 注意:在WPS365开放平台只需配置域名:jarvis.van333.cn(不能包含路径)
|
||||
* 授权URL中的redirect_uri参数会自动使用配置中的完整URL:https://jarvis.van333.cn/wps365-callback
|
||||
*/
|
||||
@Anonymous
|
||||
@GetMapping
|
||||
public ResponseEntity<?> oauthCallbackGet(@RequestParam(value = "code", required = false) String code,
|
||||
@RequestParam(value = "state", required = false) String state,
|
||||
@RequestParam(value = "error", required = false) String error,
|
||||
@RequestParam(value = "error_description", required = false) String errorDescription,
|
||||
@RequestParam(value = "challenge", required = false) String challenge) {
|
||||
return handleOAuthCallback(code, state, error, errorDescription, challenge, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth回调 - 通过授权码获取访问令牌(POST请求)
|
||||
* WPS365在验证回调URL时会发送POST请求进行challenge验证
|
||||
* 支持application/json和application/x-www-form-urlencoded两种格式
|
||||
*/
|
||||
@Anonymous
|
||||
@PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE, MediaType.TEXT_PLAIN_VALUE})
|
||||
public ResponseEntity<?> oauthCallbackPost(@RequestParam(value = "code", required = false) String code,
|
||||
@RequestParam(value = "state", required = false) String state,
|
||||
@RequestParam(value = "error", required = false) String error,
|
||||
@RequestParam(value = "error_description", required = false) String errorDescription,
|
||||
@RequestParam(value = "challenge", required = false) String challenge,
|
||||
@RequestBody(required = false) String requestBody) {
|
||||
log.info("收到WPS365 POST回调请求 - code: {}, challenge: {}, requestBody: {}",
|
||||
code != null ? "有" : "无",
|
||||
challenge != null ? challenge : "无",
|
||||
requestBody != null && requestBody.length() > 0 ? requestBody.substring(0, Math.min(100, requestBody.length())) : "无");
|
||||
|
||||
// 如果challenge在URL参数中,直接使用
|
||||
// 如果不在URL参数中,尝试从请求体中解析(可能是JSON或form-data)
|
||||
if (challenge == null && requestBody != null && !requestBody.trim().isEmpty()) {
|
||||
String bodyTrimmed = requestBody.trim();
|
||||
// 尝试解析JSON格式
|
||||
if (bodyTrimmed.startsWith("{")) {
|
||||
try {
|
||||
com.alibaba.fastjson2.JSONObject json = com.alibaba.fastjson2.JSON.parseObject(requestBody);
|
||||
if (json.containsKey("challenge")) {
|
||||
challenge = json.getString("challenge");
|
||||
log.info("从JSON请求体中解析到challenge: {}", challenge);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("解析JSON请求体失败", e);
|
||||
}
|
||||
}
|
||||
// 尝试解析form-urlencoded格式 (challenge=xxx)
|
||||
else if (bodyTrimmed.contains("challenge=")) {
|
||||
try {
|
||||
String[] pairs = bodyTrimmed.split("&");
|
||||
for (String pair : pairs) {
|
||||
if (pair.startsWith("challenge=")) {
|
||||
challenge = java.net.URLDecoder.decode(pair.substring("challenge=".length()), "UTF-8");
|
||||
log.info("从form-urlencoded请求体中解析到challenge: {}", challenge);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("解析form-urlencoded请求体失败", e);
|
||||
}
|
||||
}
|
||||
// 如果请求体就是challenge值本身(纯文本)
|
||||
else if (bodyTrimmed.length() < 200) {
|
||||
challenge = bodyTrimmed;
|
||||
log.info("将请求体作为challenge值: {}", challenge);
|
||||
}
|
||||
}
|
||||
|
||||
return handleOAuthCallback(code, state, error, errorDescription, challenge, requestBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理OAuth回调的核心逻辑
|
||||
*/
|
||||
private ResponseEntity<?> handleOAuthCallback(String code, String state, String error,
|
||||
String errorDescription, String challenge, String requestBody) {
|
||||
try {
|
||||
// 处理challenge验证(WPS365后台配置时用于验证回调URL)
|
||||
if (challenge != null && !challenge.trim().isEmpty()) {
|
||||
log.info("收到WPS365 challenge验证请求 - challenge: {}", challenge);
|
||||
// 尝试返回JSON格式(符合OAuth标准)
|
||||
// 格式:{"challenge": "xxx"} 或直接返回challenge值
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
String jsonResponse = "{\"challenge\":\"" + challenge + "\"}";
|
||||
return new ResponseEntity<>(jsonResponse, headers, HttpStatus.OK);
|
||||
} catch (Exception e) {
|
||||
log.warn("返回JSON格式失败,尝试纯文本格式", e);
|
||||
// 如果JSON失败,尝试纯文本
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.TEXT_PLAIN);
|
||||
return new ResponseEntity<>(challenge, headers, HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理授权错误
|
||||
if (error != null) {
|
||||
log.error("WPS365授权失败 - error: {}, error_description: {}", error, errorDescription);
|
||||
String errorMsg = errorDescription != null ? errorDescription : error;
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.TEXT_HTML);
|
||||
return new ResponseEntity<>(generateCallbackHtml(false, "授权失败: " + errorMsg, null), headers, HttpStatus.OK);
|
||||
}
|
||||
|
||||
// 如果没有code也没有challenge,可能是直接访问,显示提示信息
|
||||
if (code == null || code.trim().isEmpty()) {
|
||||
log.warn("访问回调地址但没有授权码,可能是测试访问");
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.TEXT_HTML);
|
||||
return new ResponseEntity<>(generateCallbackHtml(false, "等待授权回调... 如果没有授权码,请通过授权流程访问", null), headers, HttpStatus.OK);
|
||||
}
|
||||
|
||||
log.info("收到WPS365授权回调 - code: {}, state: {}", code, state);
|
||||
|
||||
// 使用授权码换取access_token
|
||||
WPS365TokenInfo tokenInfo = wps365OAuthService.getAccessTokenByCode(code);
|
||||
|
||||
// 验证返回的token信息
|
||||
if (tokenInfo == null || tokenInfo.getAccessToken() == null) {
|
||||
log.error("获取访问令牌失败 - tokenInfo: {}", tokenInfo);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.TEXT_HTML);
|
||||
return new ResponseEntity<>(generateCallbackHtml(false, "获取访问令牌失败,响应数据格式不正确", null), headers, HttpStatus.OK);
|
||||
}
|
||||
|
||||
log.info("成功获取访问令牌 - userId: {}, access_token: {}",
|
||||
tokenInfo.getUserId(),
|
||||
tokenInfo.getAccessToken() != null ? tokenInfo.getAccessToken().substring(0, 20) + "..." : "null");
|
||||
|
||||
// 自动保存token到后端
|
||||
try {
|
||||
if (tokenInfo.getUserId() != null) {
|
||||
wps365OAuthService.saveToken(tokenInfo.getUserId(), tokenInfo);
|
||||
log.info("访问令牌已自动保存到后端缓存 - userId: {}", tokenInfo.getUserId());
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
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状态
|
||||
*/
|
||||
@GetMapping("/tokenStatus")
|
||||
public AjaxResult getTokenStatus(@RequestParam(required = false) String userId) {
|
||||
try {
|
||||
WPS365TokenInfo tokenInfo = null;
|
||||
|
||||
// 如果提供了userId,查询指定用户的token
|
||||
if (userId != null && !userId.trim().isEmpty()) {
|
||||
tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
|
||||
} else {
|
||||
// 如果没有提供userId,尝试查找所有token(通常只有一个)
|
||||
// 这里使用getCurrentToken方法,它会尝试查找可用的token
|
||||
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());
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,3 +222,5 @@ tencent:
|
||||
# 刷新Token地址(用于通过refresh_token刷新access_token)
|
||||
refresh-token-url: https://docs.qq.com/oauth/v2/token
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,3 +9,43 @@ tencent:
|
||||
push:
|
||||
# 延迟时间(分钟),默认10分钟
|
||||
minutes: 10
|
||||
# WPS365开放平台配置
|
||||
# 文档地址:https://open.wps.cn/
|
||||
wps365:
|
||||
# 应用ID(AppId)- 需要在WPS365开放平台申请
|
||||
app-id: AK20260114NNQJKV
|
||||
# 应用密钥(AppKey)- 需要在WPS365开放平台申请,注意保密
|
||||
app-key: 4c58bc1642e5e8fa731f75af9370496a
|
||||
# 授权回调地址(需要在WPS365开放平台配置授权域名)
|
||||
# 注意:使用 /wps365-callback 路径,避免前端路由拦截
|
||||
redirect-uri: https://jarvis.van333.cn/wps365-callback
|
||||
# API基础地址
|
||||
api-base-url: https://openapi.wps.cn/api/v1
|
||||
# OAuth授权地址(正确格式:https://openapi.wps.cn/oauth2/auth)
|
||||
oauth-url: https://openapi.wps.cn/oauth2/auth
|
||||
# 获取Token地址
|
||||
token-url: https://openapi.wps.cn/oauth2/token
|
||||
# 刷新Token地址
|
||||
refresh-token-url: https://openapi.wps.cn/oauth2/token
|
||||
# OAuth授权请求的scope权限(可选)
|
||||
# 如果不配置,默认使用:kso.file.readwrite(文件读写权限,支持在线表格操作)
|
||||
#
|
||||
# ⚠️ 重要:如果报错invalid_scope,必须按以下步骤操作:
|
||||
# 1. 登录WPS365开放平台:https://open.wps.cn/
|
||||
# 2. 进入"开发配置" > "权限管理"
|
||||
# 3. 查看已申请权限的准确名称(必须以 kso. 开头)
|
||||
# 4. 在下方配置scope,使用英文逗号分隔(WPS365官方要求)
|
||||
#
|
||||
# 根据WPS365官方文档(https://open.wps.cn/documents/app-integration-dev/wps365/server/):
|
||||
# - 必须使用英文逗号分隔(不是空格)
|
||||
# - 权限名称必须以 kso. 开头,格式如:kso.file.read, kso.file.readwrite
|
||||
# - 常见权限名称:
|
||||
# * kso.file.read (文件读取)
|
||||
# * kso.file.readwrite (文件读写,支持在线表格操作)
|
||||
# * kso.doclib.readwrite (文档库读写)
|
||||
# * kso.wiki.readwrite (知识库读写)
|
||||
#
|
||||
# 示例配置(根据平台后台实际显示的权限名称修改):
|
||||
# scope: kso.file.readwrite
|
||||
# scope: kso.file.read,kso.file.readwrite
|
||||
# scope: kso.doclib.readwrite
|
||||
|
||||
@@ -75,6 +75,17 @@
|
||||
<logger name="com.ruoyi" level="info" />
|
||||
<!-- Spring日志级别控制 -->
|
||||
<logger name="org.springframework" level="warn" />
|
||||
<!-- MyBatis SQL日志级别控制 - 关闭SQL打印 -->
|
||||
<logger name="org.apache.ibatis" level="warn" />
|
||||
<logger name="java.sql" level="warn" />
|
||||
<logger name="java.sql.Connection" level="warn" />
|
||||
<logger name="java.sql.Statement" level="warn" />
|
||||
<logger name="java.sql.PreparedStatement" level="warn" />
|
||||
<logger name="java.sql.ResultSet" level="warn" />
|
||||
<!-- MyBatis Mapper接口日志级别控制 - 关闭Mapper SQL打印 -->
|
||||
<logger name="com.ruoyi.jarvis.mapper" level="warn" />
|
||||
<logger name="com.ruoyi.system.mapper" level="warn" />
|
||||
<logger name="com.ruoyi.erp.mapper" level="warn" />
|
||||
|
||||
<root level="info">
|
||||
<appender-ref ref="console" />
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ruoyi.jarvis.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* WPS365开放平台配置
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
@Configuration
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "wps365")
|
||||
public class WPS365Config {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WPS365Config.class);
|
||||
|
||||
/** 应用ID(AppId) */
|
||||
private String appId;
|
||||
|
||||
/** 应用密钥(AppKey) */
|
||||
private String appKey;
|
||||
|
||||
/** 授权回调地址 */
|
||||
private String redirectUri;
|
||||
|
||||
/** API基础地址 */
|
||||
private String apiBaseUrl = "https://openapi.wps.cn/api/v1";
|
||||
|
||||
/** OAuth授权地址 */
|
||||
private String oauthUrl = "https://openapi.wps.cn/oauth2/auth";
|
||||
|
||||
/** 获取Token地址 */
|
||||
private String tokenUrl = "https://openapi.wps.cn/oauth2/token";
|
||||
|
||||
/** 刷新Token地址 */
|
||||
private String refreshTokenUrl = "https://openapi.wps.cn/oauth2/token";
|
||||
|
||||
/** OAuth授权请求的scope权限(可选,如果不配置则使用默认值) */
|
||||
private String scope;
|
||||
|
||||
/**
|
||||
* 配置初始化后验证
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("WPS365配置加载 - appId: {}, redirectUri: {}, apiBaseUrl: {}",
|
||||
appId != null && appId.length() > 10 ? appId.substring(0, 10) + "..." : (appId != null ? appId : "null"),
|
||||
redirectUri != null ? redirectUri : "null",
|
||||
apiBaseUrl);
|
||||
|
||||
if (appId == null || appId.trim().isEmpty()) {
|
||||
log.warn("WPS365应用ID未配置!请检查application.yml中的wps365.app-id");
|
||||
}
|
||||
if (appKey == null || appKey.trim().isEmpty()) {
|
||||
log.warn("WPS365应用密钥未配置!请检查application.yml中的wps365.app-key");
|
||||
}
|
||||
if (redirectUri == null || redirectUri.trim().isEmpty()) {
|
||||
log.warn("WPS365回调地址未配置!请检查application.yml中的wps365.redirect-uri");
|
||||
}
|
||||
}
|
||||
|
||||
public String getAppId() {
|
||||
return appId;
|
||||
}
|
||||
|
||||
public void setAppId(String appId) {
|
||||
this.appId = appId;
|
||||
}
|
||||
|
||||
public String getAppKey() {
|
||||
return appKey;
|
||||
}
|
||||
|
||||
public void setAppKey(String appKey) {
|
||||
this.appKey = appKey;
|
||||
}
|
||||
|
||||
public String getRedirectUri() {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
public void setRedirectUri(String redirectUri) {
|
||||
this.redirectUri = redirectUri;
|
||||
}
|
||||
|
||||
public String getApiBaseUrl() {
|
||||
return apiBaseUrl;
|
||||
}
|
||||
|
||||
public void setApiBaseUrl(String apiBaseUrl) {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
|
||||
public String getOauthUrl() {
|
||||
return oauthUrl;
|
||||
}
|
||||
|
||||
public void setOauthUrl(String oauthUrl) {
|
||||
this.oauthUrl = oauthUrl;
|
||||
}
|
||||
|
||||
public String getTokenUrl() {
|
||||
return tokenUrl;
|
||||
}
|
||||
|
||||
public void setTokenUrl(String tokenUrl) {
|
||||
this.tokenUrl = tokenUrl;
|
||||
}
|
||||
|
||||
public String getRefreshTokenUrl() {
|
||||
return refreshTokenUrl;
|
||||
}
|
||||
|
||||
public void setRefreshTokenUrl(String refreshTokenUrl) {
|
||||
this.refreshTokenUrl = refreshTokenUrl;
|
||||
}
|
||||
|
||||
public String getScope() {
|
||||
return scope;
|
||||
}
|
||||
|
||||
public void setScope(String scope) {
|
||||
this.scope = scope;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.jarvis.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 评论统计信息
|
||||
@@ -30,5 +31,8 @@ public class CommentStatistics {
|
||||
|
||||
/** 今日调用次数 */
|
||||
private Long todayCallCount;
|
||||
|
||||
/** 最后一条评论的创建时间(作为更新日期) */
|
||||
private Date lastCommentUpdateTime;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.ruoyi.jarvis.domain.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* WPS365 Token信息
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
public class WPS365TokenInfo implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 访问令牌 */
|
||||
private String accessToken;
|
||||
|
||||
/** 刷新令牌 */
|
||||
private String refreshToken;
|
||||
|
||||
/** 令牌类型 */
|
||||
private String tokenType;
|
||||
|
||||
/** 过期时间(秒) */
|
||||
private Integer expiresIn;
|
||||
|
||||
/** 作用域 */
|
||||
private String scope;
|
||||
|
||||
/** 用户ID */
|
||||
private String userId;
|
||||
|
||||
/** 创建时间戳(毫秒) */
|
||||
private Long createTime;
|
||||
|
||||
public WPS365TokenInfo() {
|
||||
this.createTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public void setAccessToken(String accessToken) {
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public void setRefreshToken(String refreshToken) {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
public String getTokenType() {
|
||||
return tokenType;
|
||||
}
|
||||
|
||||
public void setTokenType(String tokenType) {
|
||||
this.tokenType = tokenType;
|
||||
}
|
||||
|
||||
public Integer getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public void setExpiresIn(Integer expiresIn) {
|
||||
this.expiresIn = expiresIn;
|
||||
}
|
||||
|
||||
public String getScope() {
|
||||
return scope;
|
||||
}
|
||||
|
||||
public void setScope(String scope) {
|
||||
this.scope = scope;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public Long getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public void setCreateTime(Long createTime) {
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否过期
|
||||
*/
|
||||
public boolean isExpired() {
|
||||
if (expiresIn == null || createTime == null) {
|
||||
return true;
|
||||
}
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long expireTime = createTime + (expiresIn * 1000L);
|
||||
// 提前5分钟认为过期,留出刷新时间
|
||||
return currentTime >= (expireTime - 5 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,15 @@ public class CommentServiceImpl implements ICommentService {
|
||||
stats.setTotalCount(((Number) statMap.get("totalCount")).longValue());
|
||||
stats.setAvailableCount(((Number) statMap.get("availableCount")).longValue());
|
||||
stats.setUsedCount(((Number) statMap.get("usedCount")).longValue());
|
||||
// 设置最后一条评论的创建时间
|
||||
Object lastUpdateTime = statMap.get("lastCommentUpdateTime");
|
||||
if (lastUpdateTime != null) {
|
||||
if (lastUpdateTime instanceof Date) {
|
||||
stats.setLastCommentUpdateTime((Date) lastUpdateTime);
|
||||
} else if (lastUpdateTime instanceof java.sql.Timestamp) {
|
||||
stats.setLastCommentUpdateTime(new Date(((java.sql.Timestamp) lastUpdateTime).getTime()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取接口调用统计
|
||||
|
||||
@@ -91,14 +91,7 @@ public class InstructionServiceImpl implements IInstructionService {
|
||||
}
|
||||
// TF/H/生/拼多多 生成类指令
|
||||
else if (input.startsWith("TF")) {
|
||||
String tfResult = handleTF(input, forceGenerate);
|
||||
// 如果包含错误码,按 \n\n 分割成多个结果
|
||||
if (tfResult != null && (tfResult.contains("ERROR_CODE:ADDRESS_DUPLICATE") || tfResult.contains("ERROR_CODE:ORDER_NUMBER_DUPLICATE"))) {
|
||||
String[] parts = tfResult.split("\n\n");
|
||||
result = new ArrayList<>(Arrays.asList(parts));
|
||||
} else {
|
||||
result = Collections.singletonList(tfResult);
|
||||
}
|
||||
result = handleTF(input, forceGenerate);
|
||||
} else if (input.startsWith("H")) {
|
||||
result = Collections.singletonList(handleH(input, forceGenerate));
|
||||
} else if (input.startsWith("W")) {
|
||||
@@ -294,7 +287,11 @@ public class InstructionServiceImpl implements IInstructionService {
|
||||
list.forEach(order -> order.setDistributionMark(truncateDistributionMark(order.getDistributionMark())));
|
||||
// 过滤条件:时间范围 + 参与统计标记(isCountEnabled为null或1表示参与统计)
|
||||
List<JDOrder> filtered = list.stream().filter(o -> o.getOrderTime() != null && !o.getOrderTime().before(start) && !o.getOrderTime().after(end) && (o.getIsCountEnabled() == null || o.getIsCountEnabled() == 1)).collect(Collectors.toList());
|
||||
if (filtered.isEmpty()) return Collections.singletonList("今天没有订单。");
|
||||
if (filtered.isEmpty()) {
|
||||
LocalDate queryDate = range.get(0);
|
||||
String dateStr = queryDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
return Collections.singletonList("日期:" + dateStr + "\n" + dateStr + "没有订单。");
|
||||
}
|
||||
|
||||
Map<String, List<JDOrder>> byDM = filtered.stream().filter(o -> o.getStatus() == null || !"拍错退款".equals(o.getStatus())).collect(Collectors.groupingBy(JDOrder::getDistributionMark));
|
||||
List<String> outputs = new ArrayList<>();
|
||||
@@ -630,6 +627,17 @@ public class InstructionServiceImpl implements IInstructionService {
|
||||
}
|
||||
// ==================== 按下单人统计结束 ====================
|
||||
|
||||
// 在返回数据前,在每个元素的头部添加日期信息
|
||||
if (!outputs.isEmpty()) {
|
||||
LocalDate queryDate = range.get(0);
|
||||
String dateStr = queryDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
String dateHeader = "日期:" + dateStr + "\n";
|
||||
// 在每个原有元素的头部追加日期信息
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
outputs.set(i, dateHeader + outputs.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
return outputs.isEmpty() ? Collections.singletonList("无数据") : outputs;
|
||||
}
|
||||
if (input.startsWith("单")) {
|
||||
@@ -642,11 +650,11 @@ public class InstructionServiceImpl implements IInstructionService {
|
||||
// 产品京东配置已迁移到Redis,通过productJdConfigService获取
|
||||
// 手机号替换配置已迁移到Redis,通过phoneReplaceConfigService获取
|
||||
|
||||
private String handleTF(String input) {
|
||||
private List<String> handleTF(String input) {
|
||||
return handleTF(input, false);
|
||||
}
|
||||
|
||||
private String handleTF(String input, boolean forceGenerate) {
|
||||
private List<String> handleTF(String input, boolean forceGenerate) {
|
||||
String body = input.replaceFirst("^TF\\s*", "");
|
||||
body = body.replaceAll("[啊阿]", "");
|
||||
String[] lines = body.split("\\r?\\n+");
|
||||
@@ -742,7 +750,7 @@ public class InstructionServiceImpl implements IInstructionService {
|
||||
outputs.add("TF 指令格式:TF\t分销信息\t分销信息\t分销信息\t型号\t数量\t姓名\t电话\t地址 ;也支持多行,每行一条数据");
|
||||
}
|
||||
}
|
||||
return String.join("\n\n", outputs);
|
||||
return outputs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.common.utils.http.HttpUtils;
|
||||
import com.ruoyi.jarvis.domain.JDOrder;
|
||||
import com.ruoyi.jarvis.service.ILogisticsService;
|
||||
import com.ruoyi.jarvis.service.IJDOrderService;
|
||||
import com.ruoyi.system.service.ISysConfigService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -54,6 +55,9 @@ public class LogisticsServiceImpl implements ILogisticsService {
|
||||
@Resource
|
||||
private ISysConfigService sysConfigService;
|
||||
|
||||
@Resource
|
||||
private IJDOrderService jdOrderService;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
externalApiUrlTemplate = logisticsBaseUrl + logisticsFetchPath + "?tracking_url=";
|
||||
@@ -276,6 +280,67 @@ public class LogisticsServiceImpl implements ILogisticsService {
|
||||
|
||||
logger.info("检测到waybill_no: {} - 订单ID: {}", waybillNo, orderId);
|
||||
|
||||
// 标记物流链接是否更新
|
||||
boolean logisticsLinkUpdated = false;
|
||||
String oldLogisticsLink = null;
|
||||
String newLogisticsLink = null;
|
||||
|
||||
// 检查并更新物流链接(如果返回的数据中包含新的物流链接)
|
||||
// 尝试多种可能的字段名
|
||||
if (dataObj.containsKey("tracking_url")) {
|
||||
newLogisticsLink = dataObj.getString("tracking_url");
|
||||
} else if (dataObj.containsKey("logistics_link")) {
|
||||
newLogisticsLink = dataObj.getString("logistics_link");
|
||||
} else if (dataObj.containsKey("logisticsLink")) {
|
||||
newLogisticsLink = dataObj.getString("logisticsLink");
|
||||
} else if (dataObj.containsKey("trackingUrl")) {
|
||||
newLogisticsLink = dataObj.getString("trackingUrl");
|
||||
}
|
||||
|
||||
// 如果获取到新的物流链接,与数据库中的进行比对
|
||||
if (newLogisticsLink != null && !newLogisticsLink.trim().isEmpty()) {
|
||||
String currentLogisticsLink = order.getLogisticsLink();
|
||||
String trimmedNewLink = newLogisticsLink.trim();
|
||||
String trimmedCurrentLink = (currentLogisticsLink != null) ? currentLogisticsLink.trim() : "";
|
||||
oldLogisticsLink = trimmedCurrentLink;
|
||||
|
||||
// 比对物流链接,如果不一致则更新数据库
|
||||
if (!trimmedNewLink.equals(trimmedCurrentLink)) {
|
||||
logger.info("========== 检测到物流链接发生变化 - 订单ID: {}, 订单号: {}, 旧链接: {}, 新链接: {}, 开始更新数据库 ==========",
|
||||
orderId, order.getOrderId(), trimmedCurrentLink, trimmedNewLink);
|
||||
|
||||
try {
|
||||
// 重新查询订单,确保获取最新数据
|
||||
JDOrder updateOrder = jdOrderService.selectJDOrderById(orderId);
|
||||
if (updateOrder != null) {
|
||||
updateOrder.setLogisticsLink(trimmedNewLink);
|
||||
int updateResult = jdOrderService.updateJDOrder(updateOrder);
|
||||
if (updateResult > 0) {
|
||||
logisticsLinkUpdated = true;
|
||||
logger.info("========== 物流链接更新成功 - 订单ID: {}, 订单号: {}, 旧链接: {}, 新链接: {} ==========",
|
||||
orderId, order.getOrderId(), trimmedCurrentLink, trimmedNewLink);
|
||||
// 更新内存中的order对象,以便后续使用最新数据
|
||||
order.setLogisticsLink(trimmedNewLink);
|
||||
} else {
|
||||
logger.warn("物流链接更新失败(影响行数为0) - 订单ID: {}, 订单号: {}, 新链接: {}",
|
||||
orderId, order.getOrderId(), trimmedNewLink);
|
||||
}
|
||||
} else {
|
||||
logger.warn("未找到订单,无法更新物流链接 - 订单ID: {}, 订单号: {}", orderId, order.getOrderId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("更新物流链接时发生异常 - 订单ID: {}, 订单号: {}, 新链接: {}, 错误: {}",
|
||||
orderId, order.getOrderId(), trimmedNewLink, e.getMessage(), e);
|
||||
// 不中断主流程,继续处理
|
||||
}
|
||||
} else {
|
||||
logger.debug("物流链接未变化,无需更新 - 订单ID: {}, 订单号: {}, 链接: {}",
|
||||
orderId, order.getOrderId(), trimmedCurrentLink);
|
||||
}
|
||||
} else {
|
||||
logger.debug("返回数据中未包含物流链接字段 - 订单ID: {}, 订单号: {}", orderId, order.getOrderId());
|
||||
}
|
||||
|
||||
// 兼容处理:检查Redis中是否已有该订单的运单号记录
|
||||
// 如果存在且运单号一致,说明之前已经推送过了(可能是之前没有配置接收人但推送成功的情况)
|
||||
String redisKey = REDIS_WAYBILL_KEY_PREFIX + orderId;
|
||||
@@ -314,7 +379,7 @@ public class LogisticsServiceImpl implements ILogisticsService {
|
||||
}
|
||||
|
||||
// 调用企业应用推送,只有推送成功才记录状态
|
||||
boolean pushSuccess = sendEnterprisePushNotification(order, waybillNo);
|
||||
boolean pushSuccess = sendEnterprisePushNotification(order, waybillNo, logisticsLinkUpdated, oldLogisticsLink, newLogisticsLink);
|
||||
if (!pushSuccess) {
|
||||
logger.warn("企业微信推送未确认成功,稍后将重试 - 订单ID: {}, waybill_no: {}", orderId, waybillNo);
|
||||
return false;
|
||||
@@ -330,7 +395,14 @@ public class LogisticsServiceImpl implements ILogisticsService {
|
||||
stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS);
|
||||
}
|
||||
|
||||
logger.info("物流信息获取并推送成功 - 订单ID: {}, waybill_no: {}", orderId, waybillNo);
|
||||
// 记录最终处理结果
|
||||
if (logisticsLinkUpdated) {
|
||||
logger.info("========== 物流信息获取并推送成功(已更新物流链接) - 订单ID: {}, 订单号: {}, waybill_no: {}, 新链接: {} ==========",
|
||||
orderId, order.getOrderId(), waybillNo, newLogisticsLink);
|
||||
} else {
|
||||
logger.info("物流信息获取并推送成功 - 订单ID: {}, 订单号: {}, waybill_no: {}",
|
||||
orderId, order.getOrderId(), waybillNo);
|
||||
}
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -351,8 +423,11 @@ public class LogisticsServiceImpl implements ILogisticsService {
|
||||
* 调用企业应用推送逻辑
|
||||
* @param order 订单信息
|
||||
* @param waybillNo 运单号
|
||||
* @param logisticsLinkUpdated 物流链接是否已更新
|
||||
* @param oldLogisticsLink 旧的物流链接(如果更新了)
|
||||
* @param newLogisticsLink 新的物流链接(如果更新了)
|
||||
*/
|
||||
private boolean sendEnterprisePushNotification(JDOrder order, String waybillNo) {
|
||||
private boolean sendEnterprisePushNotification(JDOrder order, String waybillNo, boolean logisticsLinkUpdated, String oldLogisticsLink, String newLogisticsLink) {
|
||||
try {
|
||||
// 构建推送消息内容
|
||||
StringBuilder pushContent = new StringBuilder();
|
||||
@@ -370,6 +445,17 @@ public class LogisticsServiceImpl implements ILogisticsService {
|
||||
pushContent.append("型号:").append(order.getModelNumber() != null ? order.getModelNumber() : "无").append("\n");
|
||||
// 收货地址
|
||||
pushContent.append("收货地址:").append(order.getAddress() != null ? order.getAddress() : "无").append("\n");
|
||||
|
||||
// 如果物流链接已更新,在推送消息中说明
|
||||
if (logisticsLinkUpdated && newLogisticsLink != null && !newLogisticsLink.trim().isEmpty()) {
|
||||
pushContent.append("【物流链接已更新】").append("\n");
|
||||
pushContent.append("新物流链接:").append(newLogisticsLink.trim()).append("\n");
|
||||
if (oldLogisticsLink != null && !oldLogisticsLink.trim().isEmpty()) {
|
||||
pushContent.append("旧物流链接:").append(oldLogisticsLink.trim()).append("\n");
|
||||
}
|
||||
pushContent.append("\n");
|
||||
}
|
||||
|
||||
// 运单号
|
||||
pushContent.append("运单号:").append("\n").append("\n").append("\n").append("\n").append(waybillNo).append("\n");
|
||||
|
||||
@@ -393,7 +479,13 @@ public class LogisticsServiceImpl implements ILogisticsService {
|
||||
|
||||
// 记录完整的推送参数(用于调试)
|
||||
String jsonBody = pushParam.toJSONString();
|
||||
logger.info("企业微信推送完整参数 - 订单ID: {}, JSON: {}", order.getId(), jsonBody);
|
||||
if (logisticsLinkUpdated) {
|
||||
logger.info("企业微信推送完整参数(已更新物流链接) - 订单ID: {}, 订单号: {}, 旧链接: {}, 新链接: {}, JSON: {}",
|
||||
order.getId(), order.getOrderId(), oldLogisticsLink, newLogisticsLink, jsonBody);
|
||||
} else {
|
||||
logger.info("企业微信推送完整参数 - 订单ID: {}, 订单号: {}, JSON: {}",
|
||||
order.getId(), order.getOrderId(), jsonBody);
|
||||
}
|
||||
|
||||
// 使用支持自定义header的HTTP请求
|
||||
String pushResult = sendPostWithHeaders(PUSH_URL, jsonBody, PUSH_TOKEN);
|
||||
@@ -404,11 +496,16 @@ public class LogisticsServiceImpl implements ILogisticsService {
|
||||
|
||||
boolean success = isPushResponseSuccess(pushResult);
|
||||
if (success) {
|
||||
logger.info("企业应用推送成功 - 订单ID: {}, waybill_no: {}, 推送结果: {}",
|
||||
order.getId(), waybillNo, pushResult);
|
||||
if (logisticsLinkUpdated) {
|
||||
logger.info("企业应用推送成功(已更新物流链接) - 订单ID: {}, 订单号: {}, waybill_no: {}, 新链接: {}, 推送结果: {}",
|
||||
order.getId(), order.getOrderId(), waybillNo, newLogisticsLink, pushResult);
|
||||
} else {
|
||||
logger.info("企业应用推送成功 - 订单ID: {}, 订单号: {}, waybill_no: {}, 推送结果: {}",
|
||||
order.getId(), order.getOrderId(), waybillNo, pushResult);
|
||||
}
|
||||
} else {
|
||||
logger.warn("企业应用推送响应未确认成功 - 订单ID: {}, waybill_no: {}, 响应: {}",
|
||||
order.getId(), waybillNo, pushResult);
|
||||
logger.warn("企业应用推送响应未确认成功 - 订单ID: {}, 订单号: {}, waybill_no: {}, 响应: {}",
|
||||
order.getId(), order.getOrderId(), waybillNo, pushResult);
|
||||
}
|
||||
return success;
|
||||
|
||||
|
||||
@@ -429,7 +429,7 @@ public class TencentDocServiceImpl implements ITencentDocService {
|
||||
tencentDocConfig.getApiBaseUrl()
|
||||
);
|
||||
|
||||
log.info("API调用成功,原始返回结果: {}", result != null ? result.toJSONString() : "null");
|
||||
//log.info("API调用成功,原始返回结果: {}", result != null ? result.toJSONString() : "null");
|
||||
|
||||
// 检查API响应中的错误码
|
||||
// 根据官方文档,成功响应包含 ret=0,错误响应包含 code!=0
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
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) {
|
||||
try {
|
||||
// WPS365用户信息API: GET /api/v1/user/info
|
||||
// 注意:如果此API不存在或需要不同的路径,请查看WPS365官方文档
|
||||
String url = wps365Config.getApiBaseUrl() + "/user/info";
|
||||
log.debug("调用用户信息API: {}", url);
|
||||
JSONObject result = WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
log.debug("用户信息API响应: {}", result);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("获取用户信息失败 - url: {}, error: {}",
|
||||
wps365Config.getApiBaseUrl() + "/user/info", e.getMessage(), e);
|
||||
// 不抛出异常,让调用方处理(使用降级方案)
|
||||
throw new RuntimeException("获取用户信息失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getFileList(String accessToken, Map<String, Object> params) {
|
||||
try {
|
||||
// WPS365文件列表API路径可能是 /yundoc/files 而不是 /files
|
||||
// 根据WPS365 API文档,文件相关API通常在 /yundoc 路径下
|
||||
StringBuilder url = new StringBuilder(wps365Config.getApiBaseUrl() + "/yundoc/files");
|
||||
|
||||
// 添加查询参数
|
||||
if (params != null && !params.isEmpty()) {
|
||||
url.append("?");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Object> entry : params.entrySet()) {
|
||||
if (!first) {
|
||||
url.append("&");
|
||||
}
|
||||
// URL编码参数值
|
||||
try {
|
||||
String key = entry.getKey();
|
||||
String value = entry.getValue() != null ? entry.getValue().toString() : "";
|
||||
url.append(key).append("=").append(java.net.URLEncoder.encode(value, "UTF-8"));
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
url.append(entry.getKey()).append("=").append(entry.getValue());
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("调用文件列表API: {}", url.toString());
|
||||
return WPS365ApiUtil.httpRequest("GET", url.toString(), accessToken, null);
|
||||
} catch (Exception e) {
|
||||
log.error("获取文件列表失败 - url: {}", wps365Config.getApiBaseUrl() + "/yundoc/files", 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) {
|
||||
try {
|
||||
// WPS365 AirSheet API路径格式
|
||||
// 根据文档:https://open.wps.cn/documents/app-integration-dev/wps365/server/airsheet/worksheets/VbHZwButmh
|
||||
// 注意:AirSheet中,fileId和worksheetId可能是同一个值,或者worksheetId是整数索引
|
||||
// 如果用户只提供了一个ID(fileId),则fileId和worksheetId使用同一个值
|
||||
String baseUrl = "https://openapi.wps.cn/v7";
|
||||
|
||||
// 根据WPS365官方文档,AirSheet的worksheet_id必须是整数
|
||||
// 如果worksheetId为空或"0",使用0(第一个工作表)
|
||||
// 如果worksheetId与fileId相同,说明用户只配置了一个ID,尝试使用fileId作为worksheetId
|
||||
String wsId;
|
||||
if (worksheetId == null || worksheetId.trim().isEmpty() || "0".equals(worksheetId)) {
|
||||
// 默认使用0(第一个工作表)
|
||||
wsId = "0";
|
||||
} else if (worksheetId.equals(fileId)) {
|
||||
// 如果worksheetId与fileId相同,说明用户只配置了一个ID(类似腾讯文档)
|
||||
// 在AirSheet中,这个ID可能既是file_id也是worksheet_id
|
||||
wsId = fileId;
|
||||
} else {
|
||||
// 使用提供的worksheetId(应该是整数)
|
||||
wsId = worksheetId;
|
||||
}
|
||||
|
||||
// 根据官方文档:https://open.wps.cn/documents/app-integration-dev/wps365/server/airsheet/worksheets/VbHZwButmh
|
||||
// 正确路径:https://openapi.wps.cn/v7/airsheet/{file_id}/worksheets
|
||||
// 注意:路径中不需要 worksheet_id,只需要 file_id
|
||||
try {
|
||||
String url = baseUrl + "/airsheet/" + fileId + "/worksheets";
|
||||
|
||||
// 如果指定了range,添加range参数
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
|
||||
// 如果指定了worksheetId,也可以作为查询参数传递(如果API支持)
|
||||
if (worksheetId != null && !worksheetId.trim().isEmpty() && !worksheetId.equals("0") && !worksheetId.equals(fileId)) {
|
||||
if (url.contains("?")) {
|
||||
url += "&worksheet_id=" + java.net.URLEncoder.encode(worksheetId, "UTF-8");
|
||||
} else {
|
||||
url += "?worksheet_id=" + java.net.URLEncoder.encode(worksheetId, "UTF-8");
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("使用官方文档路径 - url: {}, fileId: {}, worksheetId: {}, range: {}", url, fileId, worksheetId, range);
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e) {
|
||||
log.debug("官方文档路径失败,尝试其他方案", e);
|
||||
}
|
||||
|
||||
// 尝试多种API路径格式(降级方案)
|
||||
// 方案1: 尝试使用fileId作为worksheetId(如果用户只配置了一个ID)
|
||||
if (wsId.equals("0") && !fileId.equals(worksheetId)) {
|
||||
try {
|
||||
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + fileId;
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
log.debug("尝试方案1 - 使用fileId作为worksheetId - url: {}", url);
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e) {
|
||||
log.debug("方案1失败,尝试其他方案", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 方案2: 使用 /range_data 子路径(根据官方文档,这是读取区域数据的标准路径)
|
||||
// 注意:range_data接口需要使用 row_from, row_to, col_from, col_to 参数,而不是 range=A1:B5
|
||||
try {
|
||||
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + wsId + "/range_data";
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
// 尝试解析A1:B5格式的range,转换为行列参数
|
||||
int[] rangeParams = parseRangeToRowCol(range);
|
||||
if (rangeParams != null && rangeParams.length == 4) {
|
||||
// 使用行列参数格式(row_from, row_to, col_from, col_to)
|
||||
// 注意:WPS365的行列索引可能从0开始或从1开始,需要测试确认
|
||||
url += "?row_from=" + rangeParams[0] + "&row_to=" + rangeParams[1]
|
||||
+ "&col_from=" + rangeParams[2] + "&col_to=" + rangeParams[3];
|
||||
} else {
|
||||
// 如果解析失败,尝试作为range参数传递
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
}
|
||||
log.debug("尝试方案2 - 使用/range_data子路径 - url: {}", url);
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e) {
|
||||
log.debug("方案2失败,尝试其他方案", e);
|
||||
}
|
||||
|
||||
// 方案3: 使用 /values 子路径
|
||||
try {
|
||||
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + wsId + "/values";
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
log.debug("尝试方案3 - 使用/values子路径 - url: {}", url);
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e) {
|
||||
log.debug("方案3失败,尝试其他方案", e);
|
||||
}
|
||||
|
||||
// 方案4: 基础路径(不带子路径)
|
||||
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + wsId;
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
log.debug("尝试方案4 - 基础路径 - url: {}", url);
|
||||
JSONObject result = WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("读取AirSheet数据失败 - fileId: {}, worksheetId: {}, range: {}", fileId, worksheetId, range, e);
|
||||
// 如果失败,尝试使用 /values 子路径
|
||||
try {
|
||||
String baseUrl = "https://openapi.wps.cn/v7";
|
||||
String wsId = (worksheetId != null && !worksheetId.trim().isEmpty() && !worksheetId.equals(fileId)) ? worksheetId : fileId;
|
||||
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + wsId + "/values";
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
log.debug("尝试使用/values子路径 - url: {}", url);
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e2) {
|
||||
log.error("使用/values子路径也失败", e2);
|
||||
throw new RuntimeException("读取AirSheet数据失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject updateAirSheetCells(String accessToken, String fileId, String worksheetId, String range, List<List<Object>> values) {
|
||||
try {
|
||||
// WPS365 AirSheet API路径格式
|
||||
// 根据文档:https://open.wps.cn/documents/app-integration-dev/wps365/server/airsheet/worksheets/VbHZwButmh
|
||||
// 正确路径:https://openapi.wps.cn/v7/airsheet/{file_id}/worksheets
|
||||
// 注意:路径中不需要 worksheet_id,只需要 file_id
|
||||
String baseUrl = "https://openapi.wps.cn/v7";
|
||||
|
||||
// 使用官方文档中的正确路径格式
|
||||
String url = baseUrl + "/airsheet/" + fileId + "/worksheets";
|
||||
|
||||
// 构建请求体
|
||||
JSONObject requestBody = new JSONObject();
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
requestBody.put("range", range);
|
||||
}
|
||||
|
||||
// 构建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);
|
||||
}
|
||||
}
|
||||
valuesArray.add(rowArray);
|
||||
}
|
||||
}
|
||||
requestBody.put("values", valuesArray);
|
||||
|
||||
// 如果指定了worksheetId,也可以添加到请求体中(如果API支持)
|
||||
if (worksheetId != null && !worksheetId.trim().isEmpty() && !worksheetId.equals("0") && !worksheetId.equals(fileId)) {
|
||||
requestBody.put("worksheet_id", worksheetId);
|
||||
}
|
||||
|
||||
String bodyStr = requestBody.toJSONString();
|
||||
log.debug("更新AirSheet数据 - url: {}, fileId: {}, worksheetId: {}, range: {}, values: {}",
|
||||
url, fileId, worksheetId, range, bodyStr);
|
||||
|
||||
return WPS365ApiUtil.httpRequest("PUT", url, accessToken, bodyStr);
|
||||
} catch (Exception e) {
|
||||
log.error("更新AirSheet数据失败 - fileId: {}, worksheetId: {}, range: {}", fileId, worksheetId, range, e);
|
||||
throw new RuntimeException("更新AirSheet数据失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
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 (知识库读写)
|
||||
// - 对于在线表格(AirSheet/KSheet),可能需要 kso.file.readwrite
|
||||
//
|
||||
// 如果报错invalid_scope,请:
|
||||
// 1. 登录WPS365开放平台:https://open.wps.cn/
|
||||
// 2. 进入"开发配置" > "权限管理"
|
||||
// 3. 查看已申请权限的准确名称(必须以 kso. 开头)
|
||||
// 4. 在application.yml中配置scope参数,使用逗号分隔
|
||||
scope = "kso.file.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public class LogisticsScanTask {
|
||||
* Cron表达式格式:0 每N分钟 * * * ? 表示每N分钟执行一次
|
||||
* 只扫描最近30天的订单
|
||||
*/
|
||||
@Scheduled(cron = "0 */20 * * * ?")
|
||||
@Scheduled(cron = "0 */10 * * * ?")
|
||||
public void scanAndFetchLogistics() {
|
||||
logger.info("========== 开始执行物流信息扫描定时任务(最近30天订单) ==========");
|
||||
|
||||
@@ -55,7 +55,7 @@ public class LogisticsScanTask {
|
||||
try {
|
||||
// 检查Redis中是否已处理过(避免重复处理)
|
||||
if (logisticsService.isOrderProcessed(order.getId())) {
|
||||
logger.debug("订单已处理过,跳过 - 订单ID: {}", order.getId());
|
||||
//logger.debug("订单已处理过,跳过 - 订单ID: {}", order.getId());
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
select
|
||||
count(*) as totalCount,
|
||||
sum(case when is_use = 0 then 1 else 0 end) as availableCount,
|
||||
sum(case when is_use = 1 then 1 else 0 end) as usedCount
|
||||
sum(case when is_use = 1 then 1 else 0 end) as usedCount,
|
||||
(select max(created_at) from comments where product_id = #{productId}) as lastCommentUpdateTime
|
||||
from comments
|
||||
where product_id = #{productId}
|
||||
</select>
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
select
|
||||
count(*) as totalCount,
|
||||
sum(case when is_use = 0 then 1 else 0 end) as availableCount,
|
||||
sum(case when is_use = 1 then 1 else 0 end) as usedCount
|
||||
sum(case when is_use = 1 then 1 else 0 end) as usedCount,
|
||||
(select max(created_at) from taobao_comments where product_id = #{productId}) as lastCommentUpdateTime
|
||||
from taobao_comments
|
||||
where product_id = #{productId}
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user