1
This commit is contained in:
178
doc/腾讯文档同步物流使用说明.md
Normal file
178
doc/腾讯文档同步物流使用说明.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 腾讯文档同步物流使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
系统已配置好腾讯文档开放平台的应用信息,实现了自动获取和管理访问令牌的功能。用户只需完成首次授权,后续系统会自动使用有效的访问令牌。
|
||||
|
||||
## 配置信息
|
||||
|
||||
应用信息已配置在 `application-dev.yml` 中:
|
||||
- **应用ID**: `90aa0b70e7704c2abd2a42695d5144a4`
|
||||
- **应用密钥**: `G8ZdSWcoViIawygo7JSolE86PL32UO0O`
|
||||
|
||||
## 首次授权流程
|
||||
|
||||
### 1. 获取授权URL
|
||||
|
||||
访问接口获取授权URL:
|
||||
```
|
||||
GET /jarvis/tendoc/authUrl
|
||||
```
|
||||
|
||||
或者直接在浏览器访问:
|
||||
```
|
||||
https://docs.qq.com/oauth/v2/authorize?client_id=90aa0b70e7704c2abd2a42695d5144a4&redirect_uri=YOUR_CALLBACK_URL&response_type=code&scope=all&state=RANDOM_STATE
|
||||
```
|
||||
|
||||
### 2. 完成授权
|
||||
|
||||
1. 在授权页面完成授权
|
||||
2. 授权成功后,腾讯文档会重定向到回调地址
|
||||
3. **系统会自动保存访问令牌到Redis**,无需手动操作
|
||||
|
||||
### 3. 验证授权
|
||||
|
||||
访问接口检查token状态:
|
||||
```
|
||||
GET /jarvis/tendoc/tokenStatus
|
||||
```
|
||||
|
||||
返回示例:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "访问令牌有效",
|
||||
"data": {
|
||||
"hasToken": true,
|
||||
"token": "90aa0b70e7704c2abd2..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 1. 在订单列表页面
|
||||
|
||||
1. 找到有物流链接的订单
|
||||
2. 点击"同步物流"按钮
|
||||
3. 填写文件ID和工作表ID
|
||||
|
||||
### 2. 获取文件ID和工作表ID
|
||||
|
||||
从腾讯文档URL中获取:
|
||||
```
|
||||
https://docs.qq.com/sheet/Dxxxxxxxxxxxxx?tab=BB08J2
|
||||
```
|
||||
- `Dxxxxxxxxxxxxx` 是文件ID
|
||||
- `BB08J2` 是工作表ID
|
||||
|
||||
### 3. 开始同步
|
||||
|
||||
1. 系统会自动检查后端是否有有效的访问令牌
|
||||
2. 如果有,直接开始同步
|
||||
3. 如果没有,会提示需要先完成授权
|
||||
|
||||
## Token管理
|
||||
|
||||
### 自动刷新
|
||||
|
||||
- 系统会自动检查token是否即将过期(提前5分钟)
|
||||
- 如果即将过期,会自动使用refresh_token刷新
|
||||
- 刷新后的新token会自动保存
|
||||
|
||||
### 手动设置Token(可选)
|
||||
|
||||
如果通过其他方式获取了token,可以手动设置:
|
||||
```
|
||||
POST /jarvis/tendoc/setToken
|
||||
{
|
||||
"accessToken": "xxx",
|
||||
"refreshToken": "xxx",
|
||||
"expiresIn": 7200
|
||||
}
|
||||
```
|
||||
|
||||
### 清除Token
|
||||
|
||||
如需清除token,可以调用:
|
||||
```java
|
||||
tencentDocTokenService.clearToken()
|
||||
```
|
||||
|
||||
## API接口说明
|
||||
|
||||
### 1. 获取授权URL
|
||||
- **接口**: `GET /jarvis/tendoc/authUrl`
|
||||
- **说明**: 用于首次授权,获取授权URL
|
||||
|
||||
### 2. OAuth回调
|
||||
- **接口**: `GET /jarvis/tendoc/oauth/callback?code=xxx&state=xxx`
|
||||
- **说明**: 腾讯文档授权回调,**会自动保存token到后端**
|
||||
|
||||
### 3. 检查Token状态
|
||||
- **接口**: `GET /jarvis/tendoc/tokenStatus`
|
||||
- **说明**: 检查当前token是否有效
|
||||
|
||||
### 4. 手动设置Token
|
||||
- **接口**: `POST /jarvis/tendoc/setToken`
|
||||
- **说明**: 手动设置token(可选)
|
||||
|
||||
### 5. 同步物流链接
|
||||
- **接口**: `POST /jarvis/tendoc/fillLogisticsByOrderNo`
|
||||
- **说明**: 根据单号填充物流链接,**自动使用后端保存的token**
|
||||
- **参数**:
|
||||
```json
|
||||
{
|
||||
"fileId": "文件ID",
|
||||
"sheetId": "工作表ID",
|
||||
"headerRow": 1,
|
||||
"orderNoColumn": null,
|
||||
"logisticsLinkColumn": null
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **回调地址配置**:
|
||||
- 必须在腾讯文档开放平台配置回调地址
|
||||
- 回调地址必须是HTTPS(生产环境)
|
||||
- 回调地址:`https://your-domain.com/jarvis/tendoc/oauth/callback`
|
||||
|
||||
2. **Token有效期**:
|
||||
- Access Token有效期:2小时
|
||||
- Refresh Token有效期:30天
|
||||
- 系统会自动刷新,无需手动操作
|
||||
|
||||
3. **Redis存储**:
|
||||
- Token存储在Redis中,key格式:`tendoc:token:{appId}`
|
||||
- Refresh Token key格式:`tendoc:refresh_token:{appId}`
|
||||
- 过期时间key格式:`tendoc:token_expire:{appId}`
|
||||
|
||||
4. **同步逻辑**:
|
||||
- 系统会自动从上次处理的最大行数-100开始读取
|
||||
- 避免重复处理历史数据
|
||||
- 自动识别列位置(单号列和物流链接列)
|
||||
|
||||
## 故障排查
|
||||
|
||||
### Token无效
|
||||
|
||||
如果提示"访问令牌无效":
|
||||
1. 检查是否完成首次授权
|
||||
2. 检查Redis中是否有token
|
||||
3. 尝试重新授权
|
||||
|
||||
### 授权失败
|
||||
|
||||
如果授权失败:
|
||||
1. 检查回调地址是否正确配置
|
||||
2. 检查回调地址是否在腾讯文档开放平台的白名单中
|
||||
3. 检查应用ID和应用密钥是否正确
|
||||
|
||||
### 同步失败
|
||||
|
||||
如果同步失败:
|
||||
1. 检查文件ID和工作表ID是否正确
|
||||
2. 检查表格是否有权限访问
|
||||
3. 查看后端日志获取详细错误信息
|
||||
|
||||
@@ -37,11 +37,14 @@ public class TencentDocController extends BaseController {
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Autowired
|
||||
private com.ruoyi.jarvis.service.ITencentDocTokenService tencentDocTokenService;
|
||||
|
||||
/** Redis key前缀,用于存储上次处理的最大行数 */
|
||||
private static final String LAST_PROCESSED_ROW_KEY_PREFIX = "tendoc:last_row:";
|
||||
|
||||
/**
|
||||
* 获取授权URL
|
||||
* 获取授权URL(用于手动授权,获取token后通过setToken接口保存)
|
||||
*/
|
||||
@GetMapping("/authUrl")
|
||||
public AjaxResult getAuthUrl() {
|
||||
@@ -54,6 +57,56 @@ public class TencentDocController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动设置访问令牌(用于首次授权后保存token)
|
||||
*/
|
||||
@PostMapping("/setToken")
|
||||
public AjaxResult setToken(@RequestBody Map<String, Object> params) {
|
||||
try {
|
||||
String accessToken = (String) params.get("accessToken");
|
||||
String refreshToken = (String) params.get("refreshToken");
|
||||
Integer expiresIn = params.get("expiresIn") != null ?
|
||||
Integer.valueOf(params.get("expiresIn").toString()) : 7200;
|
||||
|
||||
if (accessToken == null || accessToken.trim().isEmpty()) {
|
||||
return AjaxResult.error("accessToken不能为空");
|
||||
}
|
||||
|
||||
// 通过反射调用setToken方法
|
||||
if (tencentDocTokenService instanceof com.ruoyi.jarvis.service.impl.TencentDocTokenServiceImpl) {
|
||||
((com.ruoyi.jarvis.service.impl.TencentDocTokenServiceImpl) tencentDocTokenService)
|
||||
.setToken(accessToken, refreshToken, expiresIn);
|
||||
return AjaxResult.success("访问令牌已保存");
|
||||
} else {
|
||||
return AjaxResult.error("Token服务类型不支持");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("设置访问令牌失败", e);
|
||||
return AjaxResult.error("设置访问令牌失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前token状态
|
||||
*/
|
||||
@GetMapping("/tokenStatus")
|
||||
public AjaxResult getTokenStatus() {
|
||||
try {
|
||||
String token;
|
||||
try {
|
||||
token = tencentDocTokenService.getValidAccessToken();
|
||||
return AjaxResult.success("访问令牌有效", new JSONObject().fluentPut("hasToken", true)
|
||||
.fluentPut("token", token.substring(0, Math.min(20, token.length())) + "..."));
|
||||
} catch (Exception e) {
|
||||
return AjaxResult.success("访问令牌无效", new JSONObject().fluentPut("hasToken", false)
|
||||
.fluentPut("message", e.getMessage()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("获取token状态失败", e);
|
||||
return AjaxResult.error("获取token状态失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth回调 - 通过授权码获取访问令牌
|
||||
* 根据腾讯文档官方文档:https://docs.qq.com/open/document/app/oauth2/authorize.html
|
||||
@@ -87,9 +140,25 @@ public class TencentDocController extends BaseController {
|
||||
return AjaxResult.error("获取访问令牌失败,响应数据格式不正确");
|
||||
}
|
||||
|
||||
log.info("成功获取访问令牌 - access_token: {}", tokenInfo.getString("access_token"));
|
||||
String accessToken = tokenInfo.getString("access_token");
|
||||
String refreshToken = tokenInfo.getString("refresh_token");
|
||||
Integer expiresIn = tokenInfo.getIntValue("expires_in");
|
||||
|
||||
return AjaxResult.success("授权成功", tokenInfo);
|
||||
log.info("成功获取访问令牌 - access_token: {}", accessToken);
|
||||
|
||||
// 自动保存token到后端
|
||||
try {
|
||||
if (tencentDocTokenService instanceof com.ruoyi.jarvis.service.impl.TencentDocTokenServiceImpl) {
|
||||
((com.ruoyi.jarvis.service.impl.TencentDocTokenServiceImpl) tencentDocTokenService)
|
||||
.setToken(accessToken, refreshToken, expiresIn);
|
||||
log.info("访问令牌已自动保存到后端缓存");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("保存访问令牌失败", e);
|
||||
// 即使保存失败,也返回token信息
|
||||
}
|
||||
|
||||
return AjaxResult.success("授权成功,访问令牌已自动保存", tokenInfo);
|
||||
} catch (Exception e) {
|
||||
log.error("OAuth回调处理失败", e);
|
||||
return AjaxResult.error("授权失败: " + e.getMessage());
|
||||
@@ -282,11 +351,20 @@ public class TencentDocController extends BaseController {
|
||||
/**
|
||||
* 根据单号填充物流链接 - 读取表格数据,根据单号查询订单系统中的物流链接,并填充到表格
|
||||
* 优化:记录上次处理的最大行数,每次从最大行数-100开始读取,避免重复处理历史数据
|
||||
* 自动获取和管理访问令牌,无需前端传递
|
||||
*/
|
||||
@PostMapping("/fillLogisticsByOrderNo")
|
||||
public AjaxResult fillLogisticsByOrderNo(@RequestBody Map<String, Object> params) {
|
||||
try {
|
||||
String accessToken = (String) params.get("accessToken");
|
||||
// 自动获取有效的访问令牌
|
||||
String accessToken;
|
||||
try {
|
||||
accessToken = tencentDocTokenService.getValidAccessToken();
|
||||
} catch (Exception e) {
|
||||
log.error("获取访问令牌失败", e);
|
||||
return AjaxResult.error("获取访问令牌失败: " + e.getMessage() + "。请先完成授权或检查配置。");
|
||||
}
|
||||
|
||||
String fileId = (String) params.get("fileId");
|
||||
String sheetId = (String) params.get("sheetId");
|
||||
|
||||
|
||||
@@ -190,11 +190,11 @@ xss:
|
||||
tencent:
|
||||
doc:
|
||||
# 应用ID(需要在腾讯文档开放平台申请)
|
||||
app-id: your_app_id
|
||||
app-id: 90aa0b70e7704c2abd2a42695d5144a4
|
||||
# 应用密钥(需要在腾讯文档开放平台申请)
|
||||
app-secret: your_app_secret
|
||||
# 授权回调地址(需要在腾讯文档开放平台配置)
|
||||
redirect-uri: http://localhost:30313/jarvis/tendoc/oauth/callback
|
||||
app-secret: G8ZdSWcoViIawygo7JSolE86PL32UO0O
|
||||
# 授权回调地址(需要在腾讯文档开放平台配置,必须使用HTTPS)
|
||||
redirect-uri: https://jarvis.van333.cn/jarvis/tendoc/oauth/callback
|
||||
# API基础地址
|
||||
api-base-url: https://docs.qq.com/open/v1
|
||||
# OAuth授权地址
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ruoyi.jarvis.service;
|
||||
|
||||
/**
|
||||
* 腾讯文档Token管理服务接口
|
||||
* 用于自动获取和管理访问令牌
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
public interface ITencentDocTokenService {
|
||||
|
||||
/**
|
||||
* 获取有效的访问令牌
|
||||
* 如果当前token不存在或已过期,会自动获取新的token
|
||||
*
|
||||
* @return 访问令牌
|
||||
*/
|
||||
String getValidAccessToken();
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*
|
||||
* @return 新的访问令牌
|
||||
*/
|
||||
String refreshAccessToken();
|
||||
|
||||
/**
|
||||
* 清除缓存的token
|
||||
*/
|
||||
void clearToken();
|
||||
}
|
||||
|
||||
@@ -1364,7 +1364,16 @@ private String handleTF(String input) {
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
order.setAddress(fields.getOrDefault("地址", null));
|
||||
order.setLogisticsLink(extractFirstUrl(fields.getOrDefault("物流链接", "")));
|
||||
// 保留完整的物流链接,不做清理
|
||||
String logisticsLink = fields.getOrDefault("物流链接", null);
|
||||
if (logisticsLink != null) {
|
||||
logisticsLink = logisticsLink.trim();
|
||||
// 如果为空字符串,设置为null
|
||||
if (logisticsLink.isEmpty()) {
|
||||
logisticsLink = null;
|
||||
}
|
||||
}
|
||||
order.setLogisticsLink(logisticsLink);
|
||||
order.setOrderId(fields.getOrDefault("订单号", null));
|
||||
order.setBuyer(fields.getOrDefault("下单人", null));
|
||||
// 京粉实际价格不从表单解析,而是从数据库order_rows表中查询获取
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.ruoyi.jarvis.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.jarvis.config.TencentDocConfig;
|
||||
import com.ruoyi.jarvis.service.ITencentDocTokenService;
|
||||
import com.ruoyi.jarvis.util.TencentDocApiUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 腾讯文档Token管理服务实现
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
@Service
|
||||
public class TencentDocTokenServiceImpl implements ITencentDocTokenService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TencentDocTokenServiceImpl.class);
|
||||
|
||||
/** Redis key前缀 */
|
||||
private static final String TOKEN_KEY_PREFIX = "tendoc:token:";
|
||||
private static final String REFRESH_TOKEN_KEY_PREFIX = "tendoc:refresh_token:";
|
||||
private static final String TOKEN_EXPIRE_TIME_KEY_PREFIX = "tendoc:token_expire:";
|
||||
|
||||
@Autowired
|
||||
private TencentDocConfig tencentDocConfig;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Override
|
||||
public String getValidAccessToken() {
|
||||
String tokenKey = TOKEN_KEY_PREFIX + tencentDocConfig.getAppId();
|
||||
|
||||
// 从Redis获取缓存的token
|
||||
if (redisCache.hasKey(tokenKey)) {
|
||||
Object cachedToken = redisCache.getCacheObject(tokenKey);
|
||||
if (cachedToken != null) {
|
||||
String token = cachedToken.toString();
|
||||
|
||||
// 检查token是否即将过期(提前5分钟刷新)
|
||||
String expireTimeKey = TOKEN_EXPIRE_TIME_KEY_PREFIX + tencentDocConfig.getAppId();
|
||||
if (redisCache.hasKey(expireTimeKey)) {
|
||||
Object expireTimeObj = redisCache.getCacheObject(expireTimeKey);
|
||||
if (expireTimeObj != null) {
|
||||
Long expireTime = Long.valueOf(expireTimeObj.toString());
|
||||
long currentTime = System.currentTimeMillis();
|
||||
// 如果还有5分钟以上有效期,直接返回
|
||||
if (expireTime > currentTime + 5 * 60 * 1000) {
|
||||
log.debug("使用缓存的访问令牌");
|
||||
return token;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果没有过期时间记录,直接使用(可能是旧数据)
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Token不存在或已过期,尝试刷新
|
||||
log.info("访问令牌不存在或已过期,尝试刷新...");
|
||||
return refreshAccessToken();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String refreshAccessToken() {
|
||||
try {
|
||||
String refreshTokenKey = REFRESH_TOKEN_KEY_PREFIX + tencentDocConfig.getAppId();
|
||||
|
||||
// 先尝试使用refresh_token刷新
|
||||
if (redisCache.hasKey(refreshTokenKey)) {
|
||||
String refreshToken = redisCache.getCacheObject(refreshTokenKey).toString();
|
||||
try {
|
||||
JSONObject tokenInfo = TencentDocApiUtil.refreshAccessToken(
|
||||
tencentDocConfig.getAppId(),
|
||||
tencentDocConfig.getAppSecret(),
|
||||
refreshToken,
|
||||
tencentDocConfig.getRefreshTokenUrl()
|
||||
);
|
||||
|
||||
if (tokenInfo != null && tokenInfo.containsKey("access_token")) {
|
||||
String newAccessToken = tokenInfo.getString("access_token");
|
||||
String newRefreshToken = tokenInfo.getString("refresh_token");
|
||||
Integer expiresIn = tokenInfo.getIntValue("expires_in");
|
||||
|
||||
// 保存新的token
|
||||
saveToken(newAccessToken, newRefreshToken, expiresIn);
|
||||
|
||||
log.info("成功刷新访问令牌");
|
||||
return newAccessToken;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("使用refresh_token刷新失败,尝试重新获取授权: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有refresh_token或刷新失败,需要重新获取授权
|
||||
// 注意:服务端应用需要使用应用级授权,这里需要根据实际情况调整
|
||||
log.warn("无法自动刷新token,需要手动授权或配置应用级token");
|
||||
throw new RuntimeException("无法获取访问令牌,请检查配置或手动授权");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("刷新访问令牌失败", e);
|
||||
throw new RuntimeException("刷新访问令牌失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存token到Redis
|
||||
*/
|
||||
private void saveToken(String accessToken, String refreshToken, Integer expiresIn) {
|
||||
String tokenKey = TOKEN_KEY_PREFIX + tencentDocConfig.getAppId();
|
||||
String refreshTokenKey = REFRESH_TOKEN_KEY_PREFIX + tencentDocConfig.getAppId();
|
||||
String expireTimeKey = TOKEN_EXPIRE_TIME_KEY_PREFIX + tencentDocConfig.getAppId();
|
||||
|
||||
// 保存access_token(有效期比实际少5分钟,确保提前刷新)
|
||||
int cacheExpireTime = expiresIn != null ? expiresIn - 300 : 6900; // 默认2小时减5分钟
|
||||
redisCache.setCacheObject(tokenKey, accessToken, cacheExpireTime, TimeUnit.SECONDS);
|
||||
|
||||
// 保存refresh_token(有效期30天)
|
||||
if (refreshToken != null) {
|
||||
redisCache.setCacheObject(refreshTokenKey, refreshToken, 30, TimeUnit.DAYS);
|
||||
}
|
||||
|
||||
// 保存过期时间戳
|
||||
long expireTime = System.currentTimeMillis() + (expiresIn != null ? expiresIn * 1000L : 7200000L);
|
||||
redisCache.setCacheObject(expireTimeKey, expireTime, cacheExpireTime, TimeUnit.SECONDS);
|
||||
|
||||
log.info("访问令牌已保存到缓存,有效期: {} 秒", expiresIn);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动设置token(用于从外部获取的token)
|
||||
* 注意:此方法需要public,供控制器调用
|
||||
*/
|
||||
public void setToken(String accessToken, String refreshToken, Integer expiresIn) {
|
||||
saveToken(accessToken, refreshToken, expiresIn);
|
||||
log.info("手动设置访问令牌成功");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearToken() {
|
||||
String tokenKey = TOKEN_KEY_PREFIX + tencentDocConfig.getAppId();
|
||||
String refreshTokenKey = REFRESH_TOKEN_KEY_PREFIX + tencentDocConfig.getAppId();
|
||||
String expireTimeKey = TOKEN_EXPIRE_TIME_KEY_PREFIX + tencentDocConfig.getAppId();
|
||||
|
||||
redisCache.deleteObject(tokenKey);
|
||||
redisCache.deleteObject(refreshTokenKey);
|
||||
redisCache.deleteObject(expireTimeKey);
|
||||
|
||||
log.info("已清除访问令牌缓存");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user