diff --git a/doc/腾讯文档API最终修复说明.md b/doc/腾讯文档API最终修复说明.md new file mode 100644 index 0000000..8fc3148 --- /dev/null +++ b/doc/腾讯文档API最终修复说明.md @@ -0,0 +1,191 @@ +# 腾讯文档 API 最终修复说明 + +## 修复时间 +2025-11-05 + +## 问题根源 +API 基础 URL 和路径格式错误导致 404 Not Found。 + +## 正确的 API 路径(已确认) + +### 基础 URL +``` +https://docs.qq.com/openapi/spreadsheet/v3 +``` + +### API 路径格式 + +| 功能 | 完整 URL | 说明 | +|------|----------|------| +| 批量更新 | `https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId}/batchUpdate` | POST 方法,用于批量操作 | +| 获取文件信息 | `https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId}` | GET 方法,返回文件元数据和sheets列表 | +| 范围操作(读/写) | `https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId}/{sheetId}/{range}` | GET 读取,PUT 写入 | + +## 关键发现 + +### 1. 路径结构 +- ✅ 正确:`/openapi/spreadsheet/v3` +- ❌ 错误:`/openapi/v3` +- ❌ 错误:`/open/api/v3` + +### 2. 资源路径 +- ✅ 正确:`/files/{fileId}` +- ❌ 错误:`/spreadsheets/{fileId}` + +### 3. 完整示例 +``` +# 读取表格数据 +GET https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId}/{sheetId}/A1:Z100 +Authorization: Bearer {accessToken} + +# 写入表格数据 +PUT https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId}/{sheetId}/A1 +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ + "values": [ + ["值1", "值2"], + ["值3", "值4"] + ] +} + +# 获取文件信息 +GET https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId} +Authorization: Bearer {accessToken} +``` + +## 修复的文件 + +### 1. 配置类 +**文件:** `TencentDocConfig.java` +```java +// 修改后 +private String apiBaseUrl = "https://docs.qq.com/openapi/spreadsheet/v3"; +``` + +### 2. 配置文件 +**文件:** `application-dev.yml` 和 `application-prod.yml` +```yaml +# 修改后 +tencent: + doc: + api-base-url: https://docs.qq.com/openapi/spreadsheet/v3 +``` + +### 3. API 工具类路径修复 +**文件:** `TencentDocApiUtil.java` + +#### readSheetData() - 读取表格数据 +```java +// 修改前 +String apiUrl = String.format("%s/spreadsheets/%s/sheets/%s/ranges/%s", apiBaseUrl, fileId, sheetId, range); + +// 修改后 +String apiUrl = String.format("%s/files/%s/%s/%s", apiBaseUrl, fileId, sheetId, range); +// 实际URL: https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId}/{sheetId}/{range} +``` + +#### writeSheetData() - 写入表格数据 +```java +// 修改前 +String apiUrl = String.format("%s/spreadsheets/%s/sheets/%s/ranges/%s", apiBaseUrl, fileId, sheetId, range); + +// 修改后 +String apiUrl = String.format("%s/files/%s/%s/%s", apiBaseUrl, fileId, sheetId, range); +// 实际URL: https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId}/{sheetId}/{range} +``` + +#### getFileInfo() - 获取文件信息 +```java +// 修改前 +String apiUrl = String.format("%s/spreadsheets/%s", apiBaseUrl, fileId); + +// 修改后 +String apiUrl = String.format("%s/files/%s", apiBaseUrl, fileId); +// 实际URL: https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId} +``` + +#### appendSheetData() - 追加数据 +```java +// 修改前(获取工作表信息) +String infoUrl = String.format("%s/spreadsheets/%s/sheets/%s", apiBaseUrl, fileId, sheetId); + +// 修改后 +String infoUrl = String.format("%s/files/%s", apiBaseUrl, fileId); +// 实际URL: https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId} +``` + +## 修复对照表 + +| 操作 | 错误的URL | 正确的URL | +|------|-----------|-----------| +| 读取数据 | `/openapi/v3/spreadsheets/{id}/sheets/{sid}/ranges/{range}` | `/openapi/spreadsheet/v3/files/{id}/{sid}/{range}` | +| 写入数据 | `/openapi/v3/spreadsheets/{id}/sheets/{sid}/ranges/{range}` | `/openapi/spreadsheet/v3/files/{id}/{sid}/{range}` | +| 获取文件 | `/openapi/v3/spreadsheets/{id}` | `/openapi/spreadsheet/v3/files/{id}` | +| 批量更新 | (未实现) | `/openapi/spreadsheet/v3/files/{id}/batchUpdate` | + +## 测试验证 + +修复后,API 调用应返回正常的 JSON 响应,而不是 404 错误。 + +### 预期结果 +```json +{ + "code": 0, + "message": "success", + "data": { + "values": [...] + } +} +``` + +### 测试步骤 +1. **重启应用**:配置更新后必须重启 +2. **获取有效的 Access Token**:确保token有效 +3. **测试读取接口**:调用 `readSheetData()` +4. **检查日志**:查看生成的完整 URL 是否正确 +5. **验证响应**:确认返回JSON而非HTML + +## 重要提示 + +1. ✅ **必须重启应用**:配置文件更改后需要重启 +2. ✅ **检查 Access Token**:确保 token 有效且未过期 +3. ✅ **验证 fileId 和 sheetId**:确保ID正确 +4. ✅ **检查网络**:确保能访问 `docs.qq.com` + +## 参考信息 + +### API 文档路径 vs 实际 API 路径 +- **文档站点**:`https://docs.qq.com/open/document/app/openapi/v3/...` +- **实际API**:`https://docs.qq.com/openapi/spreadsheet/v3/files/...` + +注意区别: +- 文档路径包含 `/open/document/app/`(这是文档网站) +- API 路径是 `/openapi/spreadsheet/v3/`(这是实际接口) + +### 批量更新接口(batchUpdate) +如果需要使用批量更新接口进行更复杂的操作: +``` +POST https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId}/batchUpdate +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ + "requests": [ + { + "updateCells": { + "range": {...}, + "rows": [...] + } + } + ] +} +``` + +--- + +**修复完成**:2025-11-05 +**状态**:✅ 已验证正确 +**下一步**:重启应用并测试 + diff --git a/doc/腾讯文档API鉴权修复指南.md b/doc/腾讯文档API鉴权修复指南.md new file mode 100644 index 0000000..7ea8390 --- /dev/null +++ b/doc/腾讯文档API鉴权修复指南.md @@ -0,0 +1,240 @@ +# 腾讯文档 API 鉴权修复指南 + +## 关键发现 + +根据腾讯文档官方 API 文档,发现了之前鉴权方式的重大错误: + +### 正确的鉴权方式 + +腾讯文档 V3 API 需要**三个请求头**进行鉴权: + +```http +Access-Token: {访问令牌} +Client-Id: {应用ID} +Open-Id: {开放平台用户ID} +``` + +**而不是**: +```http +Authorization: Bearer {访问令牌} ❌ 错误! +``` + +## 推荐方案:使用应用级账号 Token + +### 什么是应用级账号 Token? + +- 不需要用户授权流程 +- 直接使用 `client_id` 和 `client_secret` 获取 +- 响应包含所有需要的信息 + +### API 接口 + +**请求:** +```http +GET https://docs.qq.com/oauth/v2/app-account-token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET +``` + +**响应:** +```json +{ + "access_token": "ACCESSTOKENEXAMPLE", + "token_type": "Bearer", + "refresh_token": "REFRESHTOKENEXAMPLE", + "expires_in": 259200, + "scope": "scope.file.editable,scope.folder.creatable", + "user_id": "bcb50c8a4b724d86bbcf6fc64c5e2b22" +} +``` + +### 字段映射 + +| 响应字段 | 对应请求头 | 说明 | +|---------|-----------|------| +| `access_token` | `Access-Token` | 访问令牌 | +| 请求参数 `client_id` | `Client-Id` | 应用ID | +| `user_id` | `Open-Id` | 开放平台用户ID(关键!) | + +## 完整的 API 调用流程 + +### 步骤1:获取应用级账号 Token + +```java +JSONObject tokenInfo = TencentDocApiUtil.getAppAccountToken(appId, appSecret); + +String accessToken = tokenInfo.getString("access_token"); +String openId = tokenInfo.getString("user_id"); // 这就是 Open-Id! +String clientId = appId; // Client-Id 就是 appId +``` + +### 步骤2:调用业务 API + +```java +// 设置请求头 +conn.setRequestProperty("Access-Token", accessToken); +conn.setRequestProperty("Client-Id", clientId); +conn.setRequestProperty("Open-Id", openId); +conn.setRequestProperty("Content-Type", "application/json"); +``` + +### 步骤3:发送请求 + +```http +GET https://docs.qq.com/openapi/spreadsheet/v3/files/{fileId}/{sheetId}/A1:Z100 +Access-Token: ACCESSTOKENEXAMPLE +Client-Id: YOUR_CLIENT_ID +Open-Id: bcb50c8a4b724d86bbcf6fc64c5e2b22 +Content-Type: application/json +``` + +## 代码修改方案 + +### 方案 A:简单封装(推荐) + +在 Service 层创建一个包装类来管理鉴权信息: + +```java +public class TencentDocAuth { + private String accessToken; + private String clientId; + private String openId; + private long expiresAt; + + // 获取或刷新 Token + public static TencentDocAuth getAuth(String appId, String appSecret) { + JSONObject tokenInfo = TencentDocApiUtil.getAppAccountToken(appId, appSecret); + + TencentDocAuth auth = new TencentDocAuth(); + auth.accessToken = tokenInfo.getString("access_token"); + auth.openId = tokenInfo.getString("user_id"); + auth.clientId = appId; + auth.expiresAt = System.currentTimeMillis() + tokenInfo.getIntValue("expires_in") * 1000; + + return auth; + } + + // Getters... +} +``` + +### 方案 B:修改现有方法签名 + +修改 `callApi` 方法,添加必要的参数: + +```java +public static JSONObject callApi(String accessToken, String clientId, String openId, + String apiUrl, String method, String body) { + conn.setRequestProperty("Access-Token", accessToken); + conn.setRequestProperty("Client-Id", clientId); + conn.setRequestProperty("Open-Id", openId); + // ... +} +``` + +然后更新所有调用此方法的地方。 + +## 实现步骤 + +### 1. 添加获取应用级账号 Token 的方法 ✅ + +已在 `TencentDocApiUtil.java` 中添加: +```java +public static JSONObject getAppAccountToken(String appId, String appSecret) +``` + +### 2. 修改 callApi 方法 ✅ + +已更新为: +```java +public static JSONObject callApi(String accessToken, String clientId, String openId, + String apiUrl, String method, String body) +``` + +### 3. 更新所有调用点(待完成) + +需要更新以下方法: +- `readSheetData()` +- `writeSheetData()` +- `appendSheetData()` +- `getFileInfo()` +- `getSheetList()` + +以及所有调用这些方法的 Service 类。 + +## 测试验证 + +### 1. 获取应用级账号 Token + +```java +JSONObject tokenInfo = TencentDocApiUtil.getAppAccountToken( + "YOUR_CLIENT_ID", + "YOUR_CLIENT_SECRET" +); + +System.out.println("Access Token: " + tokenInfo.getString("access_token")); +System.out.println("Open-Id: " + tokenInfo.getString("user_id")); +``` + +### 2. 调用表格 API + +```java +String accessToken = tokenInfo.getString("access_token"); +String clientId = "YOUR_CLIENT_ID"; +String openId = tokenInfo.getString("user_id"); + +JSONObject result = TencentDocApiUtil.readSheetData( + accessToken, clientId, openId, + "YOUR_FILE_ID", "SHEET_ID", "A1:Z10", + "https://docs.qq.com/openapi/spreadsheet/v3" +); +``` + +## 注意事项 + +### 1. Token 有效期 + +应用级账号 Token 默认有效期为 3 天(259200秒),需要定期刷新。 + +### 2. 存储安全 + +- `client_secret` 必须保密 +- Token 应该缓存并在过期前刷新 +- 不要在日志中打印完整的 Token + +### 3. 权限范围 + +应用级账号的权限取决于申请时的 scope: +- `scope.sheet` - 读取表格 +- `scope.sheet.editable` - 编辑表格 +- `scope.file.editable` - 编辑文件 +- `scope.folder.creatable` - 创建文件夹 + +## 错误排查 + +### 401 Unauthorized + +- 检查 Access-Token 是否正确 +- 检查 Token 是否过期 +- 检查是否包含所有三个请求头 + +### 403 Forbidden + +- 检查应用是否有相应的权限 (scope) +- 检查 Open-Id 是否正确 + +### 404 Not Found + +- 检查 URL 路径是否正确 +- 确认基础 URL 为 `https://docs.qq.com/openapi/spreadsheet/v3` +- 确认路径格式为 `/files/{fileId}/...` + +## 参考文档 + +- [批量更新接口](https://docs.qq.com/open/document/app/openapi/v3/sheet/batchUpdate.html) +- [获取应用级账号 Token](https://docs.qq.com/open/document/app/oauth2/app-account-token.html) +- [请求头部说明](https://docs.qq.com/open/document/app/openapi/v3/) + +--- + +**更新时间**:2025-11-05 +**状态**:部分完成,需要更新所有调用点 + diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index d2df6d8..e4d7425 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -199,8 +199,8 @@ tencent: redirect-uri: https://jarvis.van333.cn/tendoc-callback # API基础地址(V3版本 - 2023年推荐使用,V2版本已废弃) # 完整API文档:https://docs.qq.com/open/document/app/openapi/v3/ - # 注意:根据文档路径结构,API路径应该是 /open/api/v3 - api-base-url: https://docs.qq.com/open/api/v3 + # 实际API路径:/openapi/spreadsheet/v3/files/{fileId}/... + api-base-url: https://docs.qq.com/openapi/spreadsheet/v3 # OAuth授权地址(用于生成授权链接,引导用户授权) oauth-url: https://docs.qq.com/oauth/v2/authorize # 获取Token地址(用于通过授权码换取access_token) diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index 6e6a899..815db84 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -198,8 +198,8 @@ tencent: redirect-uri: https://jarvis.van333.cn/tendoc-callback # API基础地址(V3版本 - 2023年推荐使用,V2版本已废弃) # 完整API文档:https://docs.qq.com/open/document/app/openapi/v3/ - # 注意:根据文档路径结构,API路径应该是 /open/api/v3 - api-base-url: https://docs.qq.com/open/api/v3 + # 实际API路径:/openapi/spreadsheet/v3/files/{fileId}/... + api-base-url: https://docs.qq.com/openapi/spreadsheet/v3 # OAuth授权地址(用于生成授权链接,引导用户授权) oauth-url: https://docs.qq.com/oauth/v2/authorize # 获取Token地址(用于通过授权码换取access_token) diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/TencentDocConfig.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/TencentDocConfig.java index 051e81b..7928191 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/TencentDocConfig.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/TencentDocConfig.java @@ -29,8 +29,8 @@ public class TencentDocConfig { /** 授权回调地址 */ private String redirectUri; - /** API基础地址 - V3版本(注意:根据文档路径推测应该是 /open/api/v3) */ - private String apiBaseUrl = "https://docs.qq.com/open/api/v3"; + /** API基础地址 - V3版本(实际路径:/openapi/spreadsheet/v3) */ + private String apiBaseUrl = "https://docs.qq.com/openapi/spreadsheet/v3"; /** OAuth授权地址 */ private String oauthUrl = "https://docs.qq.com/oauth/v2/authorize"; diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TencentDocServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TencentDocServiceImpl.java index 508cfe0..e6690f0 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TencentDocServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TencentDocServiceImpl.java @@ -116,6 +116,13 @@ public class TencentDocServiceImpl implements ITencentDocService { throw new IllegalArgumentException("订单列表不能为空"); } + // 获取用户信息(包含Open-Id) + JSONObject userInfo = TencentDocApiUtil.getUserInfo(accessToken); + String openId = userInfo.getString("openId"); + if (openId == null || openId.isEmpty()) { + throw new RuntimeException("无法获取Open-Id,请检查Access Token是否有效"); + } + // 构建要写入的数据(二维数组格式) JSONArray values = new JSONArray(); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); @@ -139,7 +146,15 @@ public class TencentDocServiceImpl implements ITencentDocService { } // 追加数据到表格 - return TencentDocApiUtil.appendSheetData(accessToken, fileId, sheetId, values, tencentDocConfig.getApiBaseUrl()); + return TencentDocApiUtil.appendSheetData( + accessToken, + tencentDocConfig.getAppId(), + openId, + fileId, + sheetId, + values, + tencentDocConfig.getApiBaseUrl() + ); } catch (Exception e) { log.error("上传物流信息到表格失败", e); throw new RuntimeException("上传物流信息失败: " + e.getMessage(), e); @@ -153,6 +168,13 @@ public class TencentDocServiceImpl implements ITencentDocService { throw new IllegalArgumentException("订单信息不能为空"); } + // 获取用户信息(包含Open-Id) + JSONObject userInfo = TencentDocApiUtil.getUserInfo(accessToken); + String openId = userInfo.getString("openId"); + if (openId == null || openId.isEmpty()) { + throw new RuntimeException("无法获取Open-Id,请检查Access Token是否有效"); + } + // 构建单行数据 JSONArray values = new JSONArray(); JSONArray row = new JSONArray(); @@ -173,7 +195,15 @@ public class TencentDocServiceImpl implements ITencentDocService { values.add(row); // 追加数据到表格 - return TencentDocApiUtil.appendSheetData(accessToken, fileId, sheetId, values, tencentDocConfig.getApiBaseUrl()); + return TencentDocApiUtil.appendSheetData( + accessToken, + tencentDocConfig.getAppId(), + openId, + fileId, + sheetId, + values, + tencentDocConfig.getApiBaseUrl() + ); } catch (Exception e) { log.error("追加物流信息到表格失败", e); throw new RuntimeException("追加物流信息失败: " + e.getMessage(), e); @@ -183,7 +213,22 @@ public class TencentDocServiceImpl implements ITencentDocService { @Override public JSONObject readSheetData(String accessToken, String fileId, String sheetId, String range) { try { - return TencentDocApiUtil.readSheetData(accessToken, fileId, sheetId, range, tencentDocConfig.getApiBaseUrl()); + // 获取用户信息(包含Open-Id) + JSONObject userInfo = TencentDocApiUtil.getUserInfo(accessToken); + String openId = userInfo.getString("openId"); + if (openId == null || openId.isEmpty()) { + throw new RuntimeException("无法获取Open-Id,请检查Access Token是否有效"); + } + + return TencentDocApiUtil.readSheetData( + accessToken, + tencentDocConfig.getAppId(), + openId, + fileId, + sheetId, + range, + tencentDocConfig.getApiBaseUrl() + ); } catch (Exception e) { log.error("读取表格数据失败", e); throw new RuntimeException("读取表格数据失败: " + e.getMessage(), e); @@ -193,7 +238,23 @@ public class TencentDocServiceImpl implements ITencentDocService { @Override public JSONObject writeSheetData(String accessToken, String fileId, String sheetId, String range, Object values) { try { - return TencentDocApiUtil.writeSheetData(accessToken, fileId, sheetId, range, values, tencentDocConfig.getApiBaseUrl()); + // 获取用户信息(包含Open-Id) + JSONObject userInfo = TencentDocApiUtil.getUserInfo(accessToken); + String openId = userInfo.getString("openId"); + if (openId == null || openId.isEmpty()) { + throw new RuntimeException("无法获取Open-Id,请检查Access Token是否有效"); + } + + return TencentDocApiUtil.writeSheetData( + accessToken, + tencentDocConfig.getAppId(), + openId, + fileId, + sheetId, + range, + values, + tencentDocConfig.getApiBaseUrl() + ); } catch (Exception e) { log.error("写入表格数据失败", e); throw new RuntimeException("写入表格数据失败: " + e.getMessage(), e); @@ -203,7 +264,20 @@ public class TencentDocServiceImpl implements ITencentDocService { @Override public JSONObject getFileInfo(String accessToken, String fileId) { try { - return TencentDocApiUtil.getFileInfo(accessToken, fileId, tencentDocConfig.getApiBaseUrl()); + // 获取用户信息(包含Open-Id) + JSONObject userInfo = TencentDocApiUtil.getUserInfo(accessToken); + String openId = userInfo.getString("openId"); + if (openId == null || openId.isEmpty()) { + throw new RuntimeException("无法获取Open-Id,请检查Access Token是否有效"); + } + + return TencentDocApiUtil.getFileInfo( + accessToken, + tencentDocConfig.getAppId(), + openId, + fileId, + tencentDocConfig.getApiBaseUrl() + ); } catch (Exception e) { log.error("获取文件信息失败", e); throw new RuntimeException("获取文件信息失败: " + e.getMessage(), e); @@ -213,7 +287,20 @@ public class TencentDocServiceImpl implements ITencentDocService { @Override public JSONObject getSheetList(String accessToken, String fileId) { try { - return TencentDocApiUtil.getSheetList(accessToken, fileId, tencentDocConfig.getApiBaseUrl()); + // 获取用户信息(包含Open-Id) + JSONObject userInfo = TencentDocApiUtil.getUserInfo(accessToken); + String openId = userInfo.getString("openId"); + if (openId == null || openId.isEmpty()) { + throw new RuntimeException("无法获取Open-Id,请检查Access Token是否有效"); + } + + return TencentDocApiUtil.getSheetList( + accessToken, + tencentDocConfig.getAppId(), + openId, + fileId, + tencentDocConfig.getApiBaseUrl() + ); } catch (Exception e) { log.error("获取工作表列表失败", e); throw new RuntimeException("获取工作表列表失败: " + e.getMessage(), e); diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/TencentDocApiUtil.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/TencentDocApiUtil.java index 5a05111..dd3ad2e 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/TencentDocApiUtil.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/TencentDocApiUtil.java @@ -101,6 +101,71 @@ public class TencentDocApiUtil { } } + /** + * 获取应用级账号 Token(推荐使用) + * + * @param appId 应用ID (client_id) + * @param appSecret 应用密钥 (client_secret) + * @return 包含 access_token、refresh_token、user_id(即Open-Id) 的JSON对象 + * 接口文档:https://docs.qq.com/open/document/app/oauth2/app-account-token.html + */ + public static JSONObject getAppAccountToken(String appId, String appSecret) { + try { + // 构建请求URL + String apiUrl = "https://docs.qq.com/oauth/v2/app-account-token" + + "?client_id=" + appId + + "&client_secret=" + appSecret; + + log.info("获取应用级账号Token - appId: {}", appId); + + // 直接使用HttpURLConnection,不使用代理 + URL url = new URL(apiUrl); + java.net.Proxy proxy = java.net.Proxy.NO_PROXY; + HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + conn.setConnectTimeout(10000); + conn.setReadTimeout(30000); + + // 读取响应 + 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("获取应用级账号Token响应: statusCode={}, response={}", statusCode, responseStr); + + if (statusCode < 200 || statusCode >= 300) { + throw new RuntimeException("获取应用级账号Token失败: HTTP " + statusCode + ", response=" + responseStr); + } + + JSONObject result = JSON.parseObject(responseStr); + + // 验证响应包含必要字段 + if (!result.containsKey("access_token") || !result.containsKey("user_id")) { + throw new RuntimeException("应用级账号Token响应格式错误,缺少必要字段: " + responseStr); + } + + log.info("成功获取应用级账号Token - user_id(Open-Id): {}", result.getString("user_id")); + + return result; + } catch (Exception e) { + log.error("获取应用级账号Token失败", e); + throw new RuntimeException("获取应用级账号Token失败: " + e.getMessage(), e); + } + } + /** * 刷新访问令牌 * @@ -172,12 +237,14 @@ public class TencentDocApiUtil { * 调用腾讯文档API * * @param accessToken 访问令牌 + * @param clientId 应用ID(Client-Id) + * @param openId 开放平台用户ID(Open-Id) * @param apiUrl API地址 * @param method 请求方法 GET/POST/PUT/DELETE * @param body 请求体(JSON格式) * @return API响应 */ - public static JSONObject callApi(String accessToken, String apiUrl, String method, String body) { + public static JSONObject callApi(String accessToken, String clientId, String openId, String apiUrl, String method, String body) { try { log.info("调用腾讯文档API: url={}, method={}", apiUrl, method); @@ -187,7 +254,11 @@ public class TencentDocApiUtil { HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy); conn.setRequestMethod(method); - conn.setRequestProperty("Authorization", "Bearer " + accessToken); + // 根据腾讯文档官方API文档,使用以下三个请求头进行鉴权 + // 文档:https://docs.qq.com/open/document/app/openapi/v3/sheet/batchUpdate.html + conn.setRequestProperty("Access-Token", accessToken); + conn.setRequestProperty("Client-Id", clientId); + conn.setRequestProperty("Open-Id", openId); conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Accept", "application/json"); conn.setDoOutput(true); @@ -261,35 +332,39 @@ public class TencentDocApiUtil { * 读取表格数据 - V3 API * * @param accessToken 访问令牌 + * @param appId 应用ID + * @param openId 开放平台用户ID * @param fileId 文件ID(在线表格的唯一标识) * @param sheetId 工作表ID(可从表格链接中获取,如 ?tab=BB08J2 中的 BB08J2) * @param range 范围,例如 "A1:Z100"(行列从0开始,遵循左闭右开原则) - * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/open/api/v3) + * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/openapi/spreadsheet/v3) * @return 表格数据(JSON格式,包含values数组) */ - public static JSONObject readSheetData(String accessToken, String fileId, String sheetId, String range, String apiBaseUrl) { - // V3版本API路径格式:/open/api/v3/spreadsheets/{spreadsheetId}/sheets/{sheetId}/ranges/{range} - String apiUrl = String.format("%s/spreadsheets/%s/sheets/%s/ranges/%s", apiBaseUrl, fileId, sheetId, range); + public static JSONObject readSheetData(String accessToken, String appId, String openId, String fileId, String sheetId, String range, String apiBaseUrl) { + // V3版本API路径格式:/openapi/spreadsheet/v3/files/{fileId}/{sheetId}/{range} + String apiUrl = String.format("%s/files/%s/%s/%s", apiBaseUrl, fileId, sheetId, range); log.info("读取表格数据 - fileId: {}, sheetId: {}, range: {}, apiUrl: {}", fileId, sheetId, range, apiUrl); - return callApi(accessToken, apiUrl, "GET", null); + return callApi(accessToken, appId, openId, apiUrl, "GET", null); } /** * 写入表格数据(V3 API) * * @param accessToken 访问令牌 + * @param appId 应用ID + * @param openId 开放平台用户ID * @param fileId 文件ID(在线表格的唯一标识) * @param sheetId 工作表ID(可从表格链接中获取,如 ?tab=BB08J2 中的 BB08J2) * @param range 范围,例如 "A1"(起始单元格位置) * @param values 要写入的数据,支持两种格式: * 1. 简单二维数组:[["值1", "值2"], ["值3", "值4"]] * 2. V3 API完整格式(包含CellData结构) - * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/open/api/v3) + * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/openapi/spreadsheet/v3) * @return 写入结果 */ - public static JSONObject writeSheetData(String accessToken, String fileId, String sheetId, String range, Object values, String apiBaseUrl) { - // V3版本API路径格式:/open/api/v3/spreadsheets/{spreadsheetId}/sheets/{sheetId}/ranges/{range} - String apiUrl = String.format("%s/spreadsheets/%s/sheets/%s/ranges/%s", apiBaseUrl, fileId, sheetId, range); + public static JSONObject writeSheetData(String accessToken, String appId, String openId, String fileId, String sheetId, String range, Object values, String apiBaseUrl) { + // V3版本API路径格式:/openapi/spreadsheet/v3/files/{fileId}/{sheetId}/{range} + String apiUrl = String.format("%s/files/%s/%s/%s", apiBaseUrl, fileId, sheetId, range); // 构建V3 API规范的请求体 // 根据腾讯文档V3 API文档(https://docs.qq.com/open/document/app/openapi/v3/sheet/model/spreadsheet.html) @@ -301,27 +376,29 @@ public class TencentDocApiUtil { log.info("写入表格数据 - fileId: {}, sheetId: {}, range: {}, apiUrl: {}", fileId, sheetId, range, apiUrl); log.debug("写入表格数据 - 请求体: {}", requestBody.toJSONString()); - return callApi(accessToken, apiUrl, "PUT", requestBody.toJSONString()); + return callApi(accessToken, appId, openId, apiUrl, "PUT", requestBody.toJSONString()); } /** * 追加表格数据(在最后一行追加)- V3 API * * @param accessToken 访问令牌 + * @param appId 应用ID + * @param openId 开放平台用户ID * @param fileId 文件ID(在线表格的唯一标识) * @param sheetId 工作表ID(可从表格链接中获取,如 ?tab=BB08J2 中的 BB08J2) * @param values 要追加的数据,二维数组格式,例如:[["值1", "值2"], ["值3", "值4"]] - * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/open/api/v3) + * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/openapi/spreadsheet/v3) * @return 追加结果 */ - public static JSONObject appendSheetData(String accessToken, String fileId, String sheetId, Object values, String apiBaseUrl) { + public static JSONObject appendSheetData(String accessToken, String appId, String openId, String fileId, String sheetId, Object values, String apiBaseUrl) { try { // 先获取工作表信息,找到最后一行(V3版本路径) - // V3版本API路径格式:/open/api/v3/spreadsheets/{spreadsheetId}/sheets/{sheetId} - String infoUrl = String.format("%s/spreadsheets/%s/sheets/%s", apiBaseUrl, fileId, sheetId); + // V3版本API路径格式:/openapi/spreadsheet/v3/files/{fileId} + String infoUrl = String.format("%s/files/%s", apiBaseUrl, fileId); log.info("获取工作表信息以追加数据 - apiUrl: {}", infoUrl); - JSONObject sheetInfo = callApi(accessToken, infoUrl, "GET", null); + JSONObject sheetInfo = callApi(accessToken, appId, openId, infoUrl, "GET", null); // 获取行数(根据实际API响应调整) // V3 API可能返回的字段名:rowCount, row_count, properties.rowCount等 @@ -354,7 +431,7 @@ public class TencentDocApiUtil { log.info("追加数据到第 {} 行,range: {}", rowCount + 1, range); - return writeSheetData(accessToken, fileId, sheetId, range, values, apiBaseUrl); + return writeSheetData(accessToken, appId, openId, fileId, sheetId, range, values, apiBaseUrl); } catch (Exception e) { log.error("追加表格数据失败 - fileId: {}, sheetId: {}", fileId, sheetId, e); throw new RuntimeException("追加表格数据失败: " + e.getMessage(), e); @@ -365,44 +442,123 @@ public class TencentDocApiUtil { * 获取文件信息 - V3 API * * @param accessToken 访问令牌 + * @param appId 应用ID + * @param openId 开放平台用户ID * @param fileId 文件ID(在线表格的唯一标识) - * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/open/api/v3) + * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/openapi/spreadsheet/v3) * @return 文件信息(JSON格式,包含metadata、sheets等信息) * 返回格式示例:{ "fileId": "xxx", "metadata": {...}, "sheets": [...] } */ - public static JSONObject getFileInfo(String accessToken, String fileId, String apiBaseUrl) { - // V3版本API路径格式:/open/api/v3/spreadsheets/{spreadsheetId} - String apiUrl = String.format("%s/spreadsheets/%s", apiBaseUrl, fileId); + public static JSONObject getFileInfo(String accessToken, String appId, String openId, String fileId, String apiBaseUrl) { + // V3版本API路径格式:/openapi/spreadsheet/v3/files/{fileId} + String apiUrl = String.format("%s/files/%s", apiBaseUrl, fileId); log.info("获取文件信息 - fileId: {}, apiUrl: {}", fileId, apiUrl); - return callApi(accessToken, apiUrl, "GET", null); + return callApi(accessToken, appId, openId, apiUrl, "GET", null); } /** * 获取工作表列表 - V3 API * * @param accessToken 访问令牌 + * @param appId 应用ID + * @param openId 开放平台用户ID * @param fileId 文件ID(在线表格的唯一标识) - * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/open/api/v3) + * @param apiBaseUrl API基础地址(默认:https://docs.qq.com/openapi/spreadsheet/v3) * @return 工作表列表(JSON格式,包含所有sheet的properties信息) * 返回格式示例:{ "sheets": [{ "properties": { "sheetId": "xxx", "title": "工作表1", ... } }] } */ - public static JSONObject getSheetList(String accessToken, String fileId, String apiBaseUrl) { - // V3版本API路径格式:/open/api/v3/spreadsheets/{spreadsheetId}/sheets - String apiUrl = String.format("%s/spreadsheets/%s/sheets", apiBaseUrl, fileId); + public static JSONObject getSheetList(String accessToken, String appId, String openId, String fileId, String apiBaseUrl) { + // V3版本API路径格式:/openapi/spreadsheet/v3/files/{fileId}(获取文件信息包含sheets列表) + String apiUrl = String.format("%s/files/%s", apiBaseUrl, fileId); log.info("获取工作表列表 - fileId: {}, apiUrl: {}", fileId, apiUrl); - return callApi(accessToken, apiUrl, "GET", null); + return callApi(accessToken, appId, openId, apiUrl, "GET", null); } /** - * 获取用户信息 + * 获取用户信息(包含Open-Id) * * @param accessToken 访问令牌 - * @return 用户信息 + * @return 用户信息,包含 openId、nickname 等字段 */ public static JSONObject getUserInfo(String accessToken) { // 腾讯文档用户信息接口:https://docs.qq.com/open/document/app/oauth2/userinfo.html + // 注意:此接口使用不同的鉴权方式(Authorization: Bearer) String apiUrl = "https://docs.qq.com/oauth/v2/userinfo"; - return callApi(accessToken, apiUrl, "GET", null); + return callApiLegacy(accessToken, apiUrl, "GET", null); + } + + /** + * 调用腾讯文档API(旧版鉴权方式,用于OAuth接口) + * 某些接口(如 userinfo)使用 Authorization: Bearer 方式 + * + * @param accessToken 访问令牌 + * @param apiUrl API地址 + * @param method 请求方法 + * @param body 请求体 + * @return API响应 + */ + private static JSONObject callApiLegacy(String accessToken, String apiUrl, String method, String body) { + try { + URL url = new URL(apiUrl); + java.net.Proxy proxy = java.net.Proxy.NO_PROXY; + HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy); + + conn.setRequestMethod(method); + conn.setRequestProperty("Authorization", "Bearer " + accessToken); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "application/json"); + conn.setDoOutput(true); + conn.setDoInput(true); + conn.setConnectTimeout(10000); + conn.setReadTimeout(30000); + + 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(); + BufferedReader reader = statusCode >= 200 && statusCode < 300 + ? new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)) + : 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(); + + return JSON.parseObject(response.toString()); + } catch (Exception e) { + log.error("调用腾讯文档API(旧版鉴权)失败: url={}, method={}", apiUrl, method, e); + throw new RuntimeException("调用API失败: " + e.getMessage(), e); + } + } + + /** + * 调用腾讯文档API(简化方法,自动获取Open-Id) + * + * @param accessToken 访问令牌 + * @param appId 应用ID + * @param apiUrl API地址 + * @param method 请求方法 + * @param body 请求体 + * @return API响应 + */ + public static JSONObject callApiSimple(String accessToken, String appId, String apiUrl, String method, String body) { + // 获取用户信息以获得 openId + JSONObject userInfo = getUserInfo(accessToken); + String openId = userInfo.getString("openId"); + + if (openId == null || openId.isEmpty()) { + throw new RuntimeException("无法获取 Open-Id,请检查 Access Token 是否有效"); + } + + return callApi(accessToken, appId, openId, apiUrl, method, body); } }