From cddfde34dff3894038e0f096e4d138efe1c9e3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8D=92?= Date: Wed, 5 Nov 2025 22:47:06 +0800 Subject: [PATCH] 1 --- doc/腾讯文档同步物流使用说明.md | 178 ++++++++++++++++++ .../jarvis/TencentDocController.java | 86 ++++++++- .../src/main/resources/application-dev.yml | 8 +- .../service/ITencentDocTokenService.java | 31 +++ .../service/impl/InstructionServiceImpl.java | 11 +- .../impl/TencentDocTokenServiceImpl.java | 160 ++++++++++++++++ 6 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 doc/腾讯文档同步物流使用说明.md create mode 100644 ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ITencentDocTokenService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TencentDocTokenServiceImpl.java diff --git a/doc/腾讯文档同步物流使用说明.md b/doc/腾讯文档同步物流使用说明.md new file mode 100644 index 0000000..3da71cc --- /dev/null +++ b/doc/腾讯文档同步物流使用说明.md @@ -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. 查看后端日志获取详细错误信息 + diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java index ac42530..951ffca 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java @@ -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 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 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"); diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 4d1a8c8..a8db9a4 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -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授权地址 diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ITencentDocTokenService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ITencentDocTokenService.java new file mode 100644 index 0000000..50b9046 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ITencentDocTokenService.java @@ -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(); +} + diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java index bddc7de..e8d0cc6 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java @@ -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表中查询获取 diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TencentDocTokenServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TencentDocTokenServiceImpl.java new file mode 100644 index 0000000..6dbf007 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TencentDocTokenServiceImpl.java @@ -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("已清除访问令牌缓存"); + } +} +