This commit is contained in:
2025-11-06 02:32:27 +08:00
parent 350ecde455
commit ff2002642a
7 changed files with 717 additions and 43 deletions

View File

@@ -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
**状态**:✅ 已验证正确
**下一步**:重启应用并测试

View File

@@ -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
**状态**:部分完成,需要更新所有调用点

View File

@@ -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

View File

@@ -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

View File

@@ -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";

View File

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

View File

@@ -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 应用IDClient-Id
* @param openId 开放平台用户IDOpen-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);
}
}