647 lines
18 KiB
Markdown
647 lines
18 KiB
Markdown
# 腾讯文档 API 测试验证指南
|
||
|
||
## 测试目的
|
||
验证根据官方文档修复后的 API 实现是否正确工作。
|
||
|
||
## 前置条件
|
||
|
||
### 1. 获取测试凭证
|
||
访问 [腾讯文档开放平台](https://docs.qq.com/open/),创建应用并获取:
|
||
- ✅ Client ID(应用ID)
|
||
- ✅ Client Secret(应用密钥)
|
||
- ✅ Redirect URI(已配置的回调地址)
|
||
|
||
### 2. 配置测试环境
|
||
在 `application-dev.yml` 中配置:
|
||
```yaml
|
||
tencent:
|
||
doc:
|
||
app-id: YOUR_CLIENT_ID
|
||
app-secret: YOUR_CLIENT_SECRET
|
||
redirect-uri: YOUR_REDIRECT_URI
|
||
api-base-url: https://docs.qq.com/openapi/spreadsheet/v3
|
||
```
|
||
|
||
### 3. 准备测试文档
|
||
在腾讯文档中创建一个测试表格,获取:
|
||
- ✅ File ID(从 URL 中获取)
|
||
- ✅ Sheet ID(从 URL 参数 `tab` 中获取)
|
||
|
||
示例 URL:
|
||
```
|
||
https://docs.qq.com/sheet/DQXxxxxxxxxxxxxxx?tab=BB08J2
|
||
↑ ↑
|
||
File ID Sheet ID
|
||
```
|
||
|
||
---
|
||
|
||
## 测试流程
|
||
|
||
### 第 1 步:OAuth2 授权测试
|
||
|
||
#### 1.1 获取授权 URL
|
||
```java
|
||
@RestController
|
||
@RequestMapping("/test/tencent-doc")
|
||
public class TencentDocTestController {
|
||
|
||
@Autowired
|
||
private ITencentDocService tencentDocService;
|
||
|
||
@GetMapping("/auth-url")
|
||
public String getAuthUrl() {
|
||
String authUrl = tencentDocService.getAuthUrl();
|
||
System.out.println("授权 URL: " + authUrl);
|
||
return authUrl;
|
||
}
|
||
}
|
||
```
|
||
|
||
**访问测试**:
|
||
```
|
||
GET http://localhost:8080/test/tencent-doc/auth-url
|
||
```
|
||
|
||
**预期响应**:
|
||
```
|
||
https://docs.qq.com/oauth/v2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&response_type=code&scope=all&state=xxxxx
|
||
```
|
||
|
||
**手动测试**:
|
||
1. 复制授权 URL 到浏览器
|
||
2. 扫码或微信授权
|
||
3. 授权成功后会重定向到回调地址,并带上 `code` 参数
|
||
|
||
#### 1.2 获取 Access Token
|
||
```java
|
||
@GetMapping("/callback")
|
||
public JSONObject callback(@RequestParam String code) {
|
||
System.out.println("收到授权码: " + code);
|
||
|
||
JSONObject tokenResponse = tencentDocService.getAccessTokenByCode(code);
|
||
System.out.println("Token 响应: " + tokenResponse);
|
||
|
||
String accessToken = tokenResponse.getString("access_token");
|
||
String refreshToken = tokenResponse.getString("refresh_token");
|
||
Integer expiresIn = tokenResponse.getInteger("expires_in");
|
||
String userId = tokenResponse.getString("user_id");
|
||
|
||
System.out.println("Access Token: " + accessToken);
|
||
System.out.println("Refresh Token: " + refreshToken);
|
||
System.out.println("过期时间: " + expiresIn + " 秒");
|
||
System.out.println("User ID (Open ID): " + userId);
|
||
|
||
return tokenResponse;
|
||
}
|
||
```
|
||
|
||
**预期响应**(根据官方文档):
|
||
```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`
|
||
- ✅ 响应包含 `refresh_token`
|
||
- ✅ 响应包含 `user_id`(即 Open ID)
|
||
- ✅ `expires_in` 为 259200(3天)
|
||
|
||
---
|
||
|
||
### 第 2 步:获取用户信息测试(关键修复点)
|
||
|
||
```java
|
||
@GetMapping("/user-info")
|
||
public JSONObject getUserInfo(@RequestParam String accessToken) {
|
||
System.out.println("测试获取用户信息,Access Token: " + accessToken);
|
||
|
||
JSONObject result = TencentDocApiUtil.getUserInfo(accessToken);
|
||
System.out.println("完整响应: " + result.toJSONString());
|
||
|
||
// 验证响应结构
|
||
Integer ret = result.getInteger("ret");
|
||
String msg = result.getString("msg");
|
||
JSONObject data = result.getJSONObject("data");
|
||
|
||
System.out.println("业务返回码 (ret): " + ret);
|
||
System.out.println("业务返回信息 (msg): " + msg);
|
||
|
||
if (ret == 0 && data != null) {
|
||
String openID = data.getString("openID");
|
||
String nick = data.getString("nick");
|
||
String avatar = data.getString("avatar");
|
||
String source = data.getString("source");
|
||
String unionID = data.getString("unionID");
|
||
|
||
System.out.println("✓ Open ID: " + openID);
|
||
System.out.println("✓ 昵称: " + nick);
|
||
System.out.println("✓ 头像: " + avatar);
|
||
System.out.println("✓ 来源: " + source);
|
||
System.out.println("✓ Union ID: " + unionID);
|
||
} else {
|
||
System.err.println("✗ 获取用户信息失败: " + msg);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
```
|
||
|
||
**访问测试**:
|
||
```
|
||
GET http://localhost:8080/test/tencent-doc/user-info?accessToken=YOUR_ACCESS_TOKEN
|
||
```
|
||
|
||
**预期响应**(根据官方文档):
|
||
```json
|
||
{
|
||
"ret": 0,
|
||
"msg": "Succeed",
|
||
"data": {
|
||
"openID": "bcb50c8a4b724d86bbcf6fc64c5e2b22",
|
||
"nick": "用户昵称",
|
||
"avatar": "https://thirdwx.qlogo.cn/mmopen/xxx",
|
||
"source": "wx",
|
||
"unionID": "xxxxxx"
|
||
}
|
||
}
|
||
```
|
||
|
||
**验证要点**(关键!):
|
||
- ✅ HTTP 状态码为 200
|
||
- ✅ `ret` 字段为 0(表示成功)
|
||
- ✅ `msg` 字段为 "Succeed"
|
||
- ✅ `data` 对象存在且包含 `openID` 字段(注意大写 ID)
|
||
- ✅ `openID` 字段不为空
|
||
- ✅ 其他字段(nick、avatar 等)正常返回
|
||
|
||
**常见错误**:
|
||
1. 如果返回 401:Access Token 无效或过期
|
||
2. 如果返回 `ret != 0`:业务逻辑错误,查看 `msg` 信息
|
||
3. 如果 `data` 为 null:响应解析错误
|
||
|
||
---
|
||
|
||
### 第 3 步:获取文件信息测试
|
||
|
||
```java
|
||
@GetMapping("/file-info")
|
||
public JSONObject getFileInfo(
|
||
@RequestParam String accessToken,
|
||
@RequestParam String fileId
|
||
) {
|
||
System.out.println("测试获取文件信息");
|
||
System.out.println("File ID: " + fileId);
|
||
|
||
JSONObject result = tencentDocService.getFileInfo(accessToken, fileId);
|
||
System.out.println("文件信息: " + result.toJSONString());
|
||
|
||
// 解析文件信息
|
||
String fileIdResp = result.getString("fileId");
|
||
JSONObject metadata = result.getJSONObject("metadata");
|
||
JSONArray sheets = result.getJSONArray("sheets");
|
||
|
||
System.out.println("✓ 文件 ID: " + fileIdResp);
|
||
System.out.println("✓ 元数据: " + metadata);
|
||
System.out.println("✓ 工作表数量: " + (sheets != null ? sheets.size() : 0));
|
||
|
||
if (sheets != null) {
|
||
for (int i = 0; i < sheets.size(); i++) {
|
||
JSONObject sheet = sheets.getJSONObject(i);
|
||
JSONObject properties = sheet.getJSONObject("properties");
|
||
if (properties != null) {
|
||
String sheetId = properties.getString("sheetId");
|
||
String title = properties.getString("title");
|
||
Integer rowCount = properties.getInteger("rowCount");
|
||
Integer columnCount = properties.getInteger("columnCount");
|
||
|
||
System.out.println(" 工作表 " + (i + 1) + ": " + title);
|
||
System.out.println(" - Sheet ID: " + sheetId);
|
||
System.out.println(" - 行数: " + rowCount);
|
||
System.out.println(" - 列数: " + columnCount);
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
```
|
||
|
||
**访问测试**:
|
||
```
|
||
GET http://localhost:8080/test/tencent-doc/file-info?accessToken=YOUR_ACCESS_TOKEN&fileId=YOUR_FILE_ID
|
||
```
|
||
|
||
**验证要点**:
|
||
- ✅ HTTP 状态码为 200
|
||
- ✅ 返回文件 ID
|
||
- ✅ 返回工作表列表
|
||
- ✅ 每个工作表包含 properties 信息
|
||
|
||
---
|
||
|
||
### 第 4 步:读取表格数据测试
|
||
|
||
```java
|
||
@GetMapping("/read-data")
|
||
public JSONObject readData(
|
||
@RequestParam String accessToken,
|
||
@RequestParam String fileId,
|
||
@RequestParam String sheetId,
|
||
@RequestParam(defaultValue = "A1:Z10") String range
|
||
) {
|
||
System.out.println("测试读取表格数据");
|
||
System.out.println("File ID: " + fileId);
|
||
System.out.println("Sheet ID: " + sheetId);
|
||
System.out.println("Range: " + range);
|
||
|
||
JSONObject result = tencentDocService.readSheetData(
|
||
accessToken, fileId, sheetId, range
|
||
);
|
||
System.out.println("读取结果: " + result.toJSONString());
|
||
|
||
// 解析数据
|
||
JSONArray values = result.getJSONArray("values");
|
||
if (values != null && values.size() > 0) {
|
||
System.out.println("✓ 读取到 " + values.size() + " 行数据");
|
||
|
||
// 打印前 5 行
|
||
for (int i = 0; i < Math.min(5, values.size()); i++) {
|
||
JSONArray row = values.getJSONArray(i);
|
||
System.out.println(" 行 " + (i + 1) + ": " + row.toJSONString());
|
||
}
|
||
|
||
if (values.size() > 5) {
|
||
System.out.println(" ... 还有 " + (values.size() - 5) + " 行");
|
||
}
|
||
} else {
|
||
System.out.println("✓ 指定范围内没有数据");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
```
|
||
|
||
**访问测试**:
|
||
```
|
||
GET http://localhost:8080/test/tencent-doc/read-data?accessToken=YOUR_ACCESS_TOKEN&fileId=YOUR_FILE_ID&sheetId=YOUR_SHEET_ID&range=A1:Z10
|
||
```
|
||
|
||
**预期响应**:
|
||
```json
|
||
{
|
||
"values": [
|
||
["列1", "列2", "列3"],
|
||
["数据1", "数据2", "数据3"],
|
||
["数据4", "数据5", "数据6"]
|
||
]
|
||
}
|
||
```
|
||
|
||
**验证要点**:
|
||
- ✅ HTTP 状态码为 200
|
||
- ✅ 返回 `values` 数组
|
||
- ✅ 数据结构为二维数组
|
||
- ✅ 数据内容正确
|
||
|
||
---
|
||
|
||
### 第 5 步:写入表格数据测试
|
||
|
||
```java
|
||
@PostMapping("/write-data")
|
||
public JSONObject writeData(
|
||
@RequestParam String accessToken,
|
||
@RequestParam String fileId,
|
||
@RequestParam String sheetId,
|
||
@RequestParam(defaultValue = "A1") String range
|
||
) {
|
||
System.out.println("测试写入表格数据");
|
||
|
||
// 构造测试数据
|
||
Object[][] values = {
|
||
{"测试标题1", "测试标题2", "测试标题3"},
|
||
{"测试数据1", "测试数据2", "测试数据3"},
|
||
{"测试数据4", "测试数据5", "测试数据6"}
|
||
};
|
||
|
||
System.out.println("写入数据到 " + range);
|
||
|
||
JSONObject result = tencentDocService.writeSheetData(
|
||
accessToken, fileId, sheetId, range, values
|
||
);
|
||
System.out.println("写入结果: " + result.toJSONString());
|
||
|
||
return result;
|
||
}
|
||
```
|
||
|
||
**访问测试**:
|
||
```
|
||
POST http://localhost:8080/test/tencent-doc/write-data?accessToken=YOUR_ACCESS_TOKEN&fileId=YOUR_FILE_ID&sheetId=YOUR_SHEET_ID&range=A1
|
||
```
|
||
|
||
**验证要点**:
|
||
- ✅ HTTP 状态码为 200
|
||
- ✅ 写入成功
|
||
- ✅ 在腾讯文档中手动验证数据已写入
|
||
|
||
---
|
||
|
||
### 第 6 步:追加数据测试
|
||
|
||
```java
|
||
@PostMapping("/append-data")
|
||
public JSONObject appendData(
|
||
@RequestParam String accessToken,
|
||
@RequestParam String fileId,
|
||
@RequestParam String sheetId
|
||
) {
|
||
System.out.println("测试追加表格数据");
|
||
|
||
// 构造测试数据
|
||
Object[][] values = {
|
||
{"追加行1-列1", "追加行1-列2", "追加行1-列3"},
|
||
{"追加行2-列1", "追加行2-列2", "追加行2-列3"}
|
||
};
|
||
|
||
System.out.println("追加 " + values.length + " 行数据");
|
||
|
||
// 注意:appendSheetData 内部会自动查找最后一行并追加
|
||
// 这里需要使用 TencentDocApiUtil 直接调用
|
||
JSONObject result = TencentDocApiUtil.appendSheetData(
|
||
accessToken,
|
||
tencentDocConfig.getAppId(),
|
||
getOpenID(accessToken), // 辅助方法
|
||
fileId,
|
||
sheetId,
|
||
values,
|
||
tencentDocConfig.getApiBaseUrl()
|
||
);
|
||
|
||
System.out.println("追加结果: " + result.toJSONString());
|
||
|
||
return result;
|
||
}
|
||
|
||
// 辅助方法:获取 Open ID
|
||
private String getOpenID(String accessToken) {
|
||
JSONObject userInfo = TencentDocApiUtil.getUserInfo(accessToken);
|
||
JSONObject data = userInfo.getJSONObject("data");
|
||
return data.getString("openID");
|
||
}
|
||
```
|
||
|
||
**验证要点**:
|
||
- ✅ HTTP 状态码为 200
|
||
- ✅ 数据追加到表格末尾
|
||
- ✅ 在腾讯文档中手动验证数据位置正确
|
||
|
||
---
|
||
|
||
## 完整测试流程示例
|
||
|
||
```java
|
||
@RestController
|
||
@RequestMapping("/test/tencent-doc")
|
||
public class TencentDocTestController {
|
||
|
||
@Autowired
|
||
private ITencentDocService tencentDocService;
|
||
|
||
@Autowired
|
||
private TencentDocConfig tencentDocConfig;
|
||
|
||
/**
|
||
* 完整流程测试
|
||
*/
|
||
@GetMapping("/full-test")
|
||
public Map<String, Object> fullTest(
|
||
@RequestParam String accessToken,
|
||
@RequestParam String fileId,
|
||
@RequestParam String sheetId
|
||
) {
|
||
Map<String, Object> results = new LinkedHashMap<>();
|
||
|
||
try {
|
||
// 1. 获取用户信息
|
||
System.out.println("\n=== 第1步:获取用户信息 ===");
|
||
JSONObject userInfo = TencentDocApiUtil.getUserInfo(accessToken);
|
||
JSONObject data = userInfo.getJSONObject("data");
|
||
String openID = data.getString("openID");
|
||
|
||
results.put("1_userInfo", Map.of(
|
||
"status", "success",
|
||
"openID", openID,
|
||
"nick", data.getString("nick")
|
||
));
|
||
System.out.println("✓ 用户信息获取成功,Open ID: " + openID);
|
||
|
||
// 2. 获取文件信息
|
||
System.out.println("\n=== 第2步:获取文件信息 ===");
|
||
JSONObject fileInfo = tencentDocService.getFileInfo(accessToken, fileId);
|
||
|
||
results.put("2_fileInfo", Map.of(
|
||
"status", "success",
|
||
"fileId", fileInfo.getString("fileId"),
|
||
"sheetCount", fileInfo.getJSONArray("sheets").size()
|
||
));
|
||
System.out.println("✓ 文件信息获取成功");
|
||
|
||
// 3. 读取表格数据
|
||
System.out.println("\n=== 第3步:读取表格数据 ===");
|
||
JSONObject readResult = tencentDocService.readSheetData(
|
||
accessToken, fileId, sheetId, "A1:Z10"
|
||
);
|
||
|
||
JSONArray values = readResult.getJSONArray("values");
|
||
results.put("3_readData", Map.of(
|
||
"status", "success",
|
||
"rowCount", values != null ? values.size() : 0
|
||
));
|
||
System.out.println("✓ 读取数据成功,共 " + (values != null ? values.size() : 0) + " 行");
|
||
|
||
// 4. 写入测试数据
|
||
System.out.println("\n=== 第4步:写入测试数据 ===");
|
||
Object[][] testData = {
|
||
{"测试时间", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())},
|
||
{"测试状态", "成功"}
|
||
};
|
||
|
||
JSONObject writeResult = tencentDocService.writeSheetData(
|
||
accessToken, fileId, sheetId, "A100", testData
|
||
);
|
||
|
||
results.put("4_writeData", Map.of(
|
||
"status", "success"
|
||
));
|
||
System.out.println("✓ 写入数据成功");
|
||
|
||
// 5. 总结
|
||
results.put("summary", Map.of(
|
||
"totalTests", 4,
|
||
"passedTests", 4,
|
||
"failedTests", 0,
|
||
"status", "✓ 所有测试通过"
|
||
));
|
||
|
||
System.out.println("\n=== 测试完成 ===");
|
||
System.out.println("✓ 所有测试通过!");
|
||
|
||
} catch (Exception e) {
|
||
results.put("error", Map.of(
|
||
"status", "failed",
|
||
"message", e.getMessage(),
|
||
"type", e.getClass().getName()
|
||
));
|
||
|
||
System.err.println("\n=== 测试失败 ===");
|
||
System.err.println("✗ 错误: " + e.getMessage());
|
||
e.printStackTrace();
|
||
}
|
||
|
||
return results;
|
||
}
|
||
}
|
||
```
|
||
|
||
**访问测试**:
|
||
```
|
||
GET http://localhost:8080/test/tencent-doc/full-test?accessToken=YOUR_ACCESS_TOKEN&fileId=YOUR_FILE_ID&sheetId=YOUR_SHEET_ID
|
||
```
|
||
|
||
---
|
||
|
||
## 常见问题排查
|
||
|
||
### 问题 1:获取用户信息返回 401
|
||
**原因**:Access Token 无效或过期
|
||
|
||
**解决方案**:
|
||
1. 检查 Access Token 是否正确
|
||
2. 使用 Refresh Token 刷新 Access Token
|
||
3. 重新进行 OAuth2 授权
|
||
|
||
### 问题 2:获取用户信息返回 `ret != 0`
|
||
**原因**:业务逻辑错误
|
||
|
||
**解决方案**:
|
||
1. 查看 `msg` 字段的具体错误信息
|
||
2. 确认 Access Token 是否有效
|
||
3. 检查网络连接
|
||
|
||
### 问题 3:无法获取 Open ID(返回 null)
|
||
**原因**:响应解析错误
|
||
|
||
**解决方案**:
|
||
1. 打印完整响应内容,检查结构
|
||
2. 确认使用 `data.getString("openID")`(大写 ID)
|
||
3. 确认响应中包含 `data` 对象
|
||
|
||
### 问题 4:表格操作返回 404
|
||
**原因**:File ID 或 Sheet ID 错误
|
||
|
||
**解决方案**:
|
||
1. 从浏览器地址栏重新获取 File ID 和 Sheet ID
|
||
2. 确认用户有权限访问该文档
|
||
3. 检查 API 路径是否正确
|
||
|
||
### 问题 5:表格操作返回 403
|
||
**原因**:权限不足
|
||
|
||
**解决方案**:
|
||
1. 确认授权时选择了正确的权限范围
|
||
2. 在腾讯文档中检查文档的分享设置
|
||
3. 确认 Access Token 对应的用户有编辑权限
|
||
|
||
---
|
||
|
||
## 测试检查清单
|
||
|
||
### OAuth2 授权 ✅
|
||
- [ ] 成功生成授权 URL
|
||
- [ ] 用户可以扫码或微信授权
|
||
- [ ] 成功获取 Access Token
|
||
- [ ] 成功获取 Refresh Token
|
||
- [ ] Access Token 有效期正确(3天)
|
||
|
||
### 用户信息 API ✅ (关键修复点)
|
||
- [ ] HTTP 请求使用查询参数 `access_token`
|
||
- [ ] 响应包含 `ret`、`msg`、`data` 字段
|
||
- [ ] `ret` 为 0 表示成功
|
||
- [ ] `data.openID` 字段存在且不为空(注意大写 ID)
|
||
- [ ] 其他用户信息(nick、avatar 等)正常
|
||
|
||
### 文件操作 API ✅
|
||
- [ ] 成功获取文件信息
|
||
- [ ] 成功获取工作表列表
|
||
- [ ] 工作表信息完整(sheetId、title、rowCount 等)
|
||
|
||
### 表格数据操作 API ✅
|
||
- [ ] 成功读取表格数据
|
||
- [ ] 数据格式为二维数组
|
||
- [ ] 成功写入表格数据
|
||
- [ ] 写入的数据在腾讯文档中可见
|
||
- [ ] 成功追加表格数据
|
||
- [ ] 追加的数据位置正确(在最后一行之后)
|
||
|
||
---
|
||
|
||
## 性能测试建议
|
||
|
||
### 1. 并发测试
|
||
测试多个用户同时调用 API 的性能表现。
|
||
|
||
### 2. 大数据量测试
|
||
测试读取和写入大量数据(如 1000 行)的性能。
|
||
|
||
### 3. API 限流测试
|
||
测试 API 调用频率限制,避免被限流。
|
||
|
||
---
|
||
|
||
**测试指南版本**:1.0
|
||
**最后更新**:2025-11-05
|
||
**适用修复版本**:根据官方文档的关键修复
|
||
|
||
---
|
||
|
||
## 快速开始
|
||
|
||
如果您想快速验证修复是否成功,执行以下最小测试:
|
||
|
||
```bash
|
||
# 1. 启动应用
|
||
cd ruoyi-java
|
||
mvn spring-boot:run
|
||
|
||
# 2. 获取授权(浏览器访问)
|
||
http://localhost:8080/test/tencent-doc/auth-url
|
||
|
||
# 3. 扫码授权后,从回调 URL 中获取 code
|
||
|
||
# 4. 测试用户信息接口(最关键)
|
||
curl "http://localhost:8080/test/tencent-doc/user-info?accessToken=YOUR_ACCESS_TOKEN"
|
||
|
||
# 预期看到:
|
||
# {
|
||
# "ret": 0,
|
||
# "msg": "Succeed",
|
||
# "data": {
|
||
# "openID": "xxx...",
|
||
# "nick": "用户昵称",
|
||
# ...
|
||
# }
|
||
# }
|
||
```
|
||
|
||
如果看到正确的响应结构,说明关键修复已生效! ✅
|
||
|