This commit is contained in:
2025-11-05 22:47:06 +08:00
parent 2335361160
commit cddfde34df
6 changed files with 465 additions and 9 deletions

View 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. 查看后端日志获取详细错误信息

View File

@@ -37,11 +37,14 @@ public class TencentDocController extends BaseController {
@Autowired @Autowired
private RedisCache redisCache; private RedisCache redisCache;
@Autowired
private com.ruoyi.jarvis.service.ITencentDocTokenService tencentDocTokenService;
/** Redis key前缀用于存储上次处理的最大行数 */ /** Redis key前缀用于存储上次处理的最大行数 */
private static final String LAST_PROCESSED_ROW_KEY_PREFIX = "tendoc:last_row:"; private static final String LAST_PROCESSED_ROW_KEY_PREFIX = "tendoc:last_row:";
/** /**
* 获取授权URL * 获取授权URL用于手动授权获取token后通过setToken接口保存
*/ */
@GetMapping("/authUrl") @GetMapping("/authUrl")
public AjaxResult getAuthUrl() { 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回调 - 通过授权码获取访问令牌 * OAuth回调 - 通过授权码获取访问令牌
* 根据腾讯文档官方文档https://docs.qq.com/open/document/app/oauth2/authorize.html * 根据腾讯文档官方文档https://docs.qq.com/open/document/app/oauth2/authorize.html
@@ -87,9 +140,25 @@ public class TencentDocController extends BaseController {
return AjaxResult.error("获取访问令牌失败,响应数据格式不正确"); 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) { } catch (Exception e) {
log.error("OAuth回调处理失败", e); log.error("OAuth回调处理失败", e);
return AjaxResult.error("授权失败: " + e.getMessage()); return AjaxResult.error("授权失败: " + e.getMessage());
@@ -282,11 +351,20 @@ public class TencentDocController extends BaseController {
/** /**
* 根据单号填充物流链接 - 读取表格数据,根据单号查询订单系统中的物流链接,并填充到表格 * 根据单号填充物流链接 - 读取表格数据,根据单号查询订单系统中的物流链接,并填充到表格
* 优化:记录上次处理的最大行数,每次从最大行数-100开始读取避免重复处理历史数据 * 优化:记录上次处理的最大行数,每次从最大行数-100开始读取避免重复处理历史数据
* 自动获取和管理访问令牌,无需前端传递
*/ */
@PostMapping("/fillLogisticsByOrderNo") @PostMapping("/fillLogisticsByOrderNo")
public AjaxResult fillLogisticsByOrderNo(@RequestBody Map<String, Object> params) { public AjaxResult fillLogisticsByOrderNo(@RequestBody Map<String, Object> params) {
try { 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 fileId = (String) params.get("fileId");
String sheetId = (String) params.get("sheetId"); String sheetId = (String) params.get("sheetId");

View File

@@ -190,11 +190,11 @@ xss:
tencent: tencent:
doc: doc:
# 应用ID需要在腾讯文档开放平台申请 # 应用ID需要在腾讯文档开放平台申请
app-id: your_app_id app-id: 90aa0b70e7704c2abd2a42695d5144a4
# 应用密钥(需要在腾讯文档开放平台申请) # 应用密钥(需要在腾讯文档开放平台申请)
app-secret: your_app_secret app-secret: G8ZdSWcoViIawygo7JSolE86PL32UO0O
# 授权回调地址(需要在腾讯文档开放平台配置) # 授权回调地址(需要在腾讯文档开放平台配置必须使用HTTPS
redirect-uri: http://localhost:30313/jarvis/tendoc/oauth/callback redirect-uri: https://jarvis.van333.cn/jarvis/tendoc/oauth/callback
# API基础地址 # API基础地址
api-base-url: https://docs.qq.com/open/v1 api-base-url: https://docs.qq.com/open/v1
# OAuth授权地址 # OAuth授权地址

View File

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

View File

@@ -1364,7 +1364,16 @@ private String handleTF(String input) {
} catch (Exception ignore) { } catch (Exception ignore) {
} }
order.setAddress(fields.getOrDefault("地址", null)); 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.setOrderId(fields.getOrDefault("订单号", null));
order.setBuyer(fields.getOrDefault("下单人", null)); order.setBuyer(fields.getOrDefault("下单人", null));
// 京粉实际价格不从表单解析而是从数据库order_rows表中查询获取 // 京粉实际价格不从表单解析而是从数据库order_rows表中查询获取

View File

@@ -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("已清除访问令牌缓存");
}
}