feishu-mcp 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -62
- package/dist/mcp/feishuMcp.js +1 -1
- package/dist/server.js +42 -22
- package/dist/services/baseService.js +121 -37
- package/dist/services/callbackService.js +37 -22
- package/dist/services/feishuApiService.js +140 -19
- package/dist/services/feishuAuthService.js +0 -137
- package/dist/utils/auth/authUtils.js +71 -0
- package/dist/utils/auth/index.js +4 -0
- package/dist/utils/auth/tokenCacheManager.js +420 -0
- package/dist/utils/auth/userAuthManager.js +104 -0
- package/dist/utils/auth/userContextManager.js +81 -0
- package/dist/utils/cache.js +0 -101
- package/dist/utils/error.js +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
你可以通过以下视频了解 MCP 的实际使用效果和操作流程:
|
|
22
22
|
|
|
23
23
|
<a href="https://www.bilibili.com/video/BV1z7MdzoEfu/?vd_source=94c14da5a71aeb01f665f159dd3d89c8">
|
|
24
|
-
<img src="image/demo.png" alt="飞书 MCP 使用演示" width="
|
|
24
|
+
<img src="image/demo.png" alt="飞书 MCP 使用演示" width="300"/>
|
|
25
25
|
</a>
|
|
26
26
|
|
|
27
27
|
<a href="https://www.bilibili.com/video/BV18z3gzdE1w/?vd_source=94c14da5a71aeb01f665f159dd3d89c8">
|
|
28
|
-
<img src="image/demo_1.png" alt="飞书 MCP 使用演示" width="
|
|
28
|
+
<img src="image/demo_1.png" alt="飞书 MCP 使用演示" width="300"/>
|
|
29
29
|
</a>
|
|
30
30
|
|
|
31
31
|
> ⭐ **Star 本项目,第一时间获取最新功能和重要更新!** 关注项目可以让你不错过任何新特性、修复和优化,助你持续高效使用。你的支持也将帮助我们更好地完善和发展项目。⭐
|
|
@@ -75,9 +75,10 @@
|
|
|
75
75
|
- ~~**批量增强**:新增批量更新、批量图片上传,单次操作效率提升50%~~ 0.0.15 ✅
|
|
76
76
|
- **流程优化**:减少多步调用,实现一键完成复杂任务
|
|
77
77
|
- ~~**支持多种凭证类型**:包括 tenant_access_token和 user_access_token,满足不同场景下的认证需求~~ (飞书应用配置发生变更) 0.0.16 ✅。
|
|
78
|
-
-
|
|
78
|
+
- ~~**支持cursor用户登录**:方便在cursor平台用户认证 不做了,没必要 ❌~~
|
|
79
79
|
- ~~**支持mermaid图表**:流程图、时序图等等,丰富文档内容~~ 0.1.11 ✅
|
|
80
80
|
- ~~**支持表格创建**:创建包含各种块类型的复杂表格,支持样式控制~~ 0.1.2 ✅
|
|
81
|
+
- ~~**支持飞书多用户user认证**:一人部署,可以多人使用~~ 0.1.3 ✅
|
|
81
82
|
|
|
82
83
|
---
|
|
83
84
|
|
|
@@ -96,29 +97,10 @@
|
|
|
96
97
|
### 方式一:使用 NPM 快速运行
|
|
97
98
|
|
|
98
99
|
```bash
|
|
99
|
-
npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret=<你的飞书应用密钥>
|
|
100
|
+
npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret=<你的飞书应用密钥> --feishu_auth_type=<tenant/user>
|
|
100
101
|
```
|
|
101
102
|
|
|
102
|
-
###
|
|
103
|
-
|
|
104
|
-
**已发布到 Smithery 平台,可访问:** https://smithery.ai/server/@cso1z/feishu-mcp
|
|
105
|
-
|
|
106
|
-
### 方式三:本地运行
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
#### 🌿 分支说明
|
|
110
|
-
|
|
111
|
-
本项目采用主分支(main)+功能分支(feature/xxx)协作模式:
|
|
112
|
-
|
|
113
|
-
- **main**
|
|
114
|
-
稳定主线分支,始终保持可用、可部署状态。所有已验证和正式发布的功能都会合并到 main 分支。
|
|
115
|
-
|
|
116
|
-
- **multi-user-token**
|
|
117
|
-
|
|
118
|
-
多用户隔离与按用户授权的 Feishu Token 获取功能开发分支。该分支支持 userKey 参数、按用户获取和缓存 Token、自定义 Token 服务等高级特性,适用于需要多用户隔离和授权场景的开发与测试。
|
|
119
|
-
> ⚠️ 该分支为 beta 版本,功能更新相较 main 分支可能会有延后。如有相关需求请在 issue 区留言,我会优先同步最新功能到该分支。
|
|
120
|
-
|
|
121
|
-
|
|
103
|
+
### 方式二:本地运行
|
|
122
104
|
1. **克隆仓库**
|
|
123
105
|
```bash
|
|
124
106
|
git clone https://github.com/cso1z/Feishu-MCP.git
|
|
@@ -131,16 +113,6 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
131
113
|
```
|
|
132
114
|
|
|
133
115
|
3. **配置环境变量(复制一份.env.example保存为.env文件)**
|
|
134
|
-
|
|
135
|
-
**macOS/Linux:**
|
|
136
|
-
```bash
|
|
137
|
-
cp .env.example .env
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
**Windows:**
|
|
141
|
-
```cmd
|
|
142
|
-
copy .env.example .env
|
|
143
|
-
```
|
|
144
116
|
|
|
145
117
|
4. **编辑 .env 文件**
|
|
146
118
|
在项目根目录下找到并用任意文本编辑器打开 `.env` 文件,填写你的飞书应用凭证:
|
|
@@ -148,6 +120,7 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
148
120
|
FEISHU_APP_ID=cli_xxxxx
|
|
149
121
|
FEISHU_APP_SECRET=xxxxx
|
|
150
122
|
PORT=3333
|
|
123
|
+
FEISHU_AUTH_TYPE=tenant/user
|
|
151
124
|
```
|
|
152
125
|
|
|
153
126
|
5. **运行服务器**
|
|
@@ -159,32 +132,12 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
159
132
|
|
|
160
133
|
### 环境变量配置
|
|
161
134
|
|
|
162
|
-
| 变量名 | 必需 | 描述
|
|
163
|
-
|
|
164
|
-
| `FEISHU_APP_ID` | ✅ | 飞书应用 ID
|
|
165
|
-
| `FEISHU_APP_SECRET` | ✅ | 飞书应用密钥
|
|
166
|
-
| `PORT` | ❌ | 服务器端口
|
|
167
|
-
| `FEISHU_AUTH_TYPE` | ❌ |
|
|
168
|
-
| `FEISHU_TOKEN_ENDPOINT` | ❌ | 获取 token 的接口地址,仅当自定义 token 管理时需要 | `http://localhost:3333/getToken` |
|
|
169
|
-
|
|
170
|
-
> **注意:**
|
|
171
|
-
> - 只有本地运行服务时支持 `user` 凭证,否则需配置 `FEISHU_TOKEN_ENDPOINT`,自行实现 token 获取与管理(可参考 `callbackService`、`feishuAuthService`)。
|
|
172
|
-
> - `FEISHU_TOKEN_ENDPOINT` 接口参数:`client_id`, `client_secret`, `token_type`(可选,tenant/user);返回参数:`access_token`, `needAuth`, `url`(需授权时), `expires_in`(单位:s)。
|
|
173
|
-
|
|
174
|
-
### 命令行参数
|
|
175
|
-
|
|
176
|
-
| 参数 | 描述 | 默认值 |
|
|
177
|
-
|------|------|-------|
|
|
178
|
-
| `--port` | 服务器监听端口 | `3333` |
|
|
179
|
-
| `--log-level` | 日志级别 (debug/info/log/warn/error/none) | `info` |
|
|
180
|
-
| `--feishu-app-id` | 飞书应用 ID | - |
|
|
181
|
-
| `--feishu-app-secret` | 飞书应用密钥 | - |
|
|
182
|
-
| `--feishu-base-url` | 飞书API基础URL | `https://open.feishu.cn/open-apis` |
|
|
183
|
-
| `--cache-enabled` | 是否启用缓存 | `true` |
|
|
184
|
-
| `--cache-ttl` | 缓存生存时间(秒) | `3600` |
|
|
185
|
-
| `--stdio` | 命令模式运行 | - |
|
|
186
|
-
| `--help` | 显示帮助菜单 | - |
|
|
187
|
-
| `--version` | 显示版本号 | - |
|
|
135
|
+
| 变量名 | 必需 | 描述 | 默认值 |
|
|
136
|
+
|--------|------|--------------------------------------------------------------------|-------|
|
|
137
|
+
| `FEISHU_APP_ID` | ✅ | 飞书应用 ID | - |
|
|
138
|
+
| `FEISHU_APP_SECRET` | ✅ | 飞书应用密钥 | - |
|
|
139
|
+
| `PORT` | ❌ | 服务器端口 | `3333` |
|
|
140
|
+
| `FEISHU_AUTH_TYPE` | ❌ | 认证凭证类型,使用 `user`(用户级,使用时是用户的身份操作飞书文档,需OAuth授权),使用 `tenant`(应用级,默认) | `tenant` |
|
|
188
141
|
|
|
189
142
|
### 配置文件方式(适用于 Cursor、Cline 等)
|
|
190
143
|
|
|
@@ -196,11 +149,12 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
196
149
|
"args": ["-y", "feishu-mcp", "--stdio"],
|
|
197
150
|
"env": {
|
|
198
151
|
"FEISHU_APP_ID": "<你的飞书应用ID>",
|
|
199
|
-
"FEISHU_APP_SECRET": "<你的飞书应用密钥>"
|
|
152
|
+
"FEISHU_APP_SECRET": "<你的飞书应用密钥>",
|
|
153
|
+
"FEISHU_AUTH_TYPE": "<tenant/user>"
|
|
200
154
|
}
|
|
201
155
|
},
|
|
202
156
|
"feishu_local": {
|
|
203
|
-
"url": "http://localhost:3333/sse"
|
|
157
|
+
"url": "http://localhost:3333/sse?:userKey=123456"
|
|
204
158
|
}
|
|
205
159
|
}
|
|
206
160
|
}
|
|
@@ -221,6 +175,8 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
221
175
|
3. ### **公式使用说明**:
|
|
222
176
|
在文本块中可以混合使用普通文本和公式元素。公式使用LaTeX语法,如:`1+2=3`、`\frac{a}{b}`、`\sqrt{x}`等。支持在同一文本块中包含多个公式和普通文本。
|
|
223
177
|
|
|
178
|
+
4. ### **使用飞书user认证**:
|
|
179
|
+
user认证与tenant认证在增加权限时是有区分的,所以**在初次由tenant切换到user时需要注意配置的权限**;为了区分不同的用户需要在配置mcp server服务的url增加query参数:userKey,**该值是用户的唯一标识 所以最好在设置时越随机越好**
|
|
224
180
|
---
|
|
225
181
|
|
|
226
182
|
## 🚨 故障排查
|
package/dist/mcp/feishuMcp.js
CHANGED
|
@@ -6,7 +6,7 @@ import { registerFeishuBlockTools } from './tools/feishuBlockTools.js';
|
|
|
6
6
|
import { registerFeishuFolderTools } from './tools/feishuFolderTools.js';
|
|
7
7
|
const serverInfo = {
|
|
8
8
|
name: "Feishu MCP Server",
|
|
9
|
-
version: "0.1.
|
|
9
|
+
version: "0.1.3",
|
|
10
10
|
};
|
|
11
11
|
const serverOptions = {
|
|
12
12
|
capabilities: { logging: {}, tools: {} },
|
package/dist/server.js
CHANGED
|
@@ -6,7 +6,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
6
6
|
import { Logger } from './utils/logger.js';
|
|
7
7
|
import { SSEConnectionManager } from './manager/sseConnectionManager.js';
|
|
8
8
|
import { FeishuMcp } from './mcp/feishuMcp.js';
|
|
9
|
-
import { callback
|
|
9
|
+
import { callback } from './services/callbackService.js';
|
|
10
|
+
import { UserAuthManager, UserContextManager, getBaseUrl, TokenCacheManager } from './utils/auth/index.js';
|
|
10
11
|
export class FeishuMcpServer {
|
|
11
12
|
constructor() {
|
|
12
13
|
Object.defineProperty(this, "connectionManager", {
|
|
@@ -15,7 +16,23 @@ export class FeishuMcpServer {
|
|
|
15
16
|
writable: true,
|
|
16
17
|
value: void 0
|
|
17
18
|
});
|
|
19
|
+
Object.defineProperty(this, "userAuthManager", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: void 0
|
|
24
|
+
});
|
|
25
|
+
Object.defineProperty(this, "userContextManager", {
|
|
26
|
+
enumerable: true,
|
|
27
|
+
configurable: true,
|
|
28
|
+
writable: true,
|
|
29
|
+
value: void 0
|
|
30
|
+
});
|
|
18
31
|
this.connectionManager = new SSEConnectionManager();
|
|
32
|
+
this.userAuthManager = UserAuthManager.getInstance();
|
|
33
|
+
this.userContextManager = UserContextManager.getInstance();
|
|
34
|
+
// 初始化TokenCacheManager,确保在启动时从文件加载缓存
|
|
35
|
+
TokenCacheManager.getInstance();
|
|
19
36
|
}
|
|
20
37
|
async connect(transport) {
|
|
21
38
|
const server = new FeishuMcp();
|
|
@@ -143,9 +160,18 @@ export class FeishuMcpServer {
|
|
|
143
160
|
}
|
|
144
161
|
});
|
|
145
162
|
app.get('/sse', async (req, res) => {
|
|
163
|
+
// 获取 userKey 参数
|
|
164
|
+
let userKey = req.query.userKey;
|
|
146
165
|
const sseTransport = new SSEServerTransport('/messages', res);
|
|
147
166
|
const sessionId = sseTransport.sessionId;
|
|
148
|
-
|
|
167
|
+
// 如果 userKey 为空,使用 sessionId 替代
|
|
168
|
+
if (!userKey) {
|
|
169
|
+
userKey = sessionId;
|
|
170
|
+
}
|
|
171
|
+
Logger.log(`[SSE Connection] New SSE connection established for sessionId ${sessionId}, userKey: ${userKey}, params:${JSON.stringify(req.params)} headers:${JSON.stringify(req.headers)} `);
|
|
172
|
+
// 创建用户会话映射
|
|
173
|
+
this.userAuthManager.createSession(sessionId, userKey);
|
|
174
|
+
Logger.log(`[UserAuth] Created session mapping: sessionId=${sessionId}, userKey=${userKey}`);
|
|
149
175
|
this.connectionManager.addConnection(sessionId, sseTransport, req, res);
|
|
150
176
|
try {
|
|
151
177
|
const tempServer = new FeishuMcp();
|
|
@@ -155,6 +181,8 @@ export class FeishuMcpServer {
|
|
|
155
181
|
catch (error) {
|
|
156
182
|
Logger.error(`[SSE Connection] Error connecting server to transport for ${sessionId}:`, error);
|
|
157
183
|
this.connectionManager.removeConnection(sessionId);
|
|
184
|
+
// 清理用户会话映射
|
|
185
|
+
this.userAuthManager.removeSession(sessionId);
|
|
158
186
|
if (!res.writableEnded) {
|
|
159
187
|
res.status(500).end('Failed to connect MCP server to transport');
|
|
160
188
|
}
|
|
@@ -163,7 +191,9 @@ export class FeishuMcpServer {
|
|
|
163
191
|
});
|
|
164
192
|
app.post('/messages', async (req, res) => {
|
|
165
193
|
const sessionId = req.query.sessionId;
|
|
166
|
-
|
|
194
|
+
// 通过 sessionId 获取 userKey
|
|
195
|
+
const userKey = this.userAuthManager.getUserKeyBySessionId(sessionId);
|
|
196
|
+
Logger.info(`[SSE messages] Received message with sessionId: ${sessionId}, userKey: ${userKey}, params: ${JSON.stringify(req.query)}, body: ${JSON.stringify(req.body)}`);
|
|
167
197
|
if (!sessionId) {
|
|
168
198
|
res.status(400).send('Missing sessionId query parameter');
|
|
169
199
|
return;
|
|
@@ -176,27 +206,17 @@ export class FeishuMcpServer {
|
|
|
176
206
|
.send(`No active connection found for sessionId: ${sessionId}`);
|
|
177
207
|
return;
|
|
178
208
|
}
|
|
179
|
-
|
|
209
|
+
// 获取 baseUrl
|
|
210
|
+
const baseUrl = getBaseUrl(req);
|
|
211
|
+
// 在用户上下文中执行 transport.handlePostMessage
|
|
212
|
+
this.userContextManager.run({
|
|
213
|
+
userKey: userKey || '',
|
|
214
|
+
baseUrl: baseUrl
|
|
215
|
+
}, async () => {
|
|
216
|
+
await transport.handlePostMessage(req, res);
|
|
217
|
+
});
|
|
180
218
|
});
|
|
181
219
|
app.get('/callback', callback);
|
|
182
|
-
app.get('/getToken', async (req, res) => {
|
|
183
|
-
const { client_id, client_secret, token_type } = req.query;
|
|
184
|
-
if (!client_id || !client_secret) {
|
|
185
|
-
res.status(400).json({ code: 400, msg: '缺少 client_id 或 client_secret' });
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
try {
|
|
189
|
-
const tokenResult = await getTokenByParams({
|
|
190
|
-
client_id: client_id,
|
|
191
|
-
client_secret: client_secret,
|
|
192
|
-
token_type: token_type
|
|
193
|
-
});
|
|
194
|
-
res.json({ code: 0, msg: 'success', data: tokenResult });
|
|
195
|
-
}
|
|
196
|
-
catch (e) {
|
|
197
|
-
res.status(500).json({ code: 500, msg: e.message || '获取token失败' });
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
220
|
app.listen(port, '0.0.0.0', () => {
|
|
201
221
|
Logger.info(`HTTP server listening on port ${port}`);
|
|
202
222
|
Logger.info(`SSE endpoint available at http://localhost:${port}/sse`);
|
|
@@ -1,28 +1,14 @@
|
|
|
1
1
|
import axios, { AxiosError } from 'axios';
|
|
2
2
|
import FormData from 'form-data';
|
|
3
3
|
import { Logger } from '../utils/logger.js';
|
|
4
|
-
import { formatErrorMessage } from '../utils/error.js';
|
|
5
|
-
import { CacheManager } from '../utils/cache.js';
|
|
4
|
+
import { formatErrorMessage, AuthRequiredError } from '../utils/error.js';
|
|
6
5
|
import { Config } from '../utils/config.js';
|
|
6
|
+
import { TokenCacheManager, UserContextManager, AuthUtils } from '../utils/auth/index.js';
|
|
7
7
|
/**
|
|
8
8
|
* API服务基类
|
|
9
9
|
* 提供通用的HTTP请求处理和认证功能
|
|
10
10
|
*/
|
|
11
11
|
export class BaseApiService {
|
|
12
|
-
constructor() {
|
|
13
|
-
Object.defineProperty(this, "accessToken", {
|
|
14
|
-
enumerable: true,
|
|
15
|
-
configurable: true,
|
|
16
|
-
writable: true,
|
|
17
|
-
value: ''
|
|
18
|
-
});
|
|
19
|
-
Object.defineProperty(this, "tokenExpireTime", {
|
|
20
|
-
enumerable: true,
|
|
21
|
-
configurable: true,
|
|
22
|
-
writable: true,
|
|
23
|
-
value: null
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
12
|
/**
|
|
27
13
|
* 处理API错误
|
|
28
14
|
* @param error 错误对象
|
|
@@ -68,9 +54,16 @@ export class BaseApiService {
|
|
|
68
54
|
* @param needsAuth 是否需要认证
|
|
69
55
|
* @param additionalHeaders 附加请求头
|
|
70
56
|
* @param responseType 响应类型
|
|
57
|
+
* @param retry 是否允许重试,默认为false
|
|
71
58
|
* @returns 响应数据
|
|
72
59
|
*/
|
|
73
|
-
async request(endpoint, method = 'GET', data, needsAuth = true, additionalHeaders, responseType) {
|
|
60
|
+
async request(endpoint, method = 'GET', data, needsAuth = true, additionalHeaders, responseType, retry = false) {
|
|
61
|
+
// 获取用户上下文
|
|
62
|
+
const userContextManager = UserContextManager.getInstance();
|
|
63
|
+
const userKey = userContextManager.getUserKey();
|
|
64
|
+
const baseUrl = userContextManager.getBaseUrl();
|
|
65
|
+
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
66
|
+
Logger.debug(`[BaseService] Request context - userKey: ${userKey}, baseUrl: ${baseUrl}`);
|
|
74
67
|
try {
|
|
75
68
|
// 构建请求URL
|
|
76
69
|
const url = `${this.getBaseUrl()}${endpoint}`;
|
|
@@ -88,7 +81,7 @@ export class BaseApiService {
|
|
|
88
81
|
}
|
|
89
82
|
// 添加认证令牌
|
|
90
83
|
if (needsAuth) {
|
|
91
|
-
const accessToken = await this.getAccessToken();
|
|
84
|
+
const accessToken = await this.getAccessToken(userKey);
|
|
92
85
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
93
86
|
}
|
|
94
87
|
// 记录请求信息
|
|
@@ -132,27 +125,26 @@ export class BaseApiService {
|
|
|
132
125
|
return response.data.data;
|
|
133
126
|
}
|
|
134
127
|
catch (error) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
128
|
+
const config = Config.getInstance().feishu;
|
|
129
|
+
// 处理授权异常
|
|
130
|
+
if (error instanceof AuthRequiredError) {
|
|
131
|
+
return this.handleAuthFailure(config.authType === "tenant", clientKey, baseUrl, userKey);
|
|
132
|
+
}
|
|
133
|
+
// 处理认证相关错误(401, 403等)
|
|
134
|
+
if (error instanceof AxiosError && error.response && (error.response.status >= 400 || error.response.status <= 499)) {
|
|
135
|
+
Logger.warn(`认证失败 (${error.response.status}): ${endpoint}`);
|
|
136
|
+
// 获取配置和token缓存管理器
|
|
137
|
+
const tokenCacheManager = TokenCacheManager.getInstance();
|
|
138
|
+
// 如果已经重试过,直接处理认证失败
|
|
139
|
+
if (retry) {
|
|
140
|
+
return this.handleAuthFailure(config.authType === "tenant", clientKey, baseUrl, userKey);
|
|
146
141
|
}
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return await this.request(endpoint, method, data, needsAuth, additionalHeaders);
|
|
142
|
+
// 根据认证类型处理token过期
|
|
143
|
+
if (config.authType === 'tenant') {
|
|
144
|
+
return this.handleTenantTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType);
|
|
151
145
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
retryError.isRetry = true;
|
|
155
|
-
this.handleApiError(retryError, `重试API请求失败 (${endpoint})`);
|
|
146
|
+
else {
|
|
147
|
+
return this.handleUserTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType, baseUrl, userKey);
|
|
156
148
|
}
|
|
157
149
|
}
|
|
158
150
|
// 处理其他错误
|
|
@@ -209,4 +201,96 @@ export class BaseApiService {
|
|
|
209
201
|
async delete(endpoint, data, needsAuth = true) {
|
|
210
202
|
return this.request(endpoint, 'DELETE', data, needsAuth);
|
|
211
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* 处理认证失败
|
|
206
|
+
* @param tenant 是否是tenant
|
|
207
|
+
* @param clientKey 客户端键
|
|
208
|
+
* @param baseUrl 基础URL
|
|
209
|
+
* @param userKey 用户键
|
|
210
|
+
*/
|
|
211
|
+
handleAuthFailure(tenant, clientKey, baseUrl, userKey) {
|
|
212
|
+
const tokenCacheManager = TokenCacheManager.getInstance();
|
|
213
|
+
if (tenant) {
|
|
214
|
+
// 租户模式:清除租户token缓存
|
|
215
|
+
Logger.info('租户模式:清除租户token缓存');
|
|
216
|
+
tokenCacheManager.removeTenantToken(clientKey);
|
|
217
|
+
throw new Error('租户访问令牌获取失败,请检查应用配置');
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
// 用户模式:清除用户token缓存并生成授权链接
|
|
221
|
+
tokenCacheManager.removeUserToken(clientKey);
|
|
222
|
+
const authUrl = this.generateUserAuthUrl(baseUrl, userKey);
|
|
223
|
+
throw new Error(`你需要在给用户展示如下信息:/“请在浏览器打开以下链接进行授权:\n\n[点击授权](${authUrl})/n`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 处理租户token过期
|
|
228
|
+
* @param tokenCacheManager token缓存管理器
|
|
229
|
+
* @param clientKey 客户端键
|
|
230
|
+
* @param endpoint 请求端点
|
|
231
|
+
* @param method 请求方法
|
|
232
|
+
* @param data 请求数据
|
|
233
|
+
* @param needsAuth 是否需要认证
|
|
234
|
+
* @param additionalHeaders 附加请求头
|
|
235
|
+
* @param responseType 响应类型
|
|
236
|
+
* @returns 响应数据
|
|
237
|
+
*/
|
|
238
|
+
async handleTenantTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType) {
|
|
239
|
+
// 租户模式:直接清除租户token缓存
|
|
240
|
+
Logger.info('租户模式:清除租户token缓存');
|
|
241
|
+
tokenCacheManager.removeTenantToken(clientKey);
|
|
242
|
+
// 重试请求
|
|
243
|
+
Logger.info('重试租户请求...');
|
|
244
|
+
return await this.request(endpoint, method, data, needsAuth, additionalHeaders, responseType, true);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* 处理用户token过期
|
|
248
|
+
* @param tokenCacheManager token缓存管理器
|
|
249
|
+
* @param clientKey 客户端键
|
|
250
|
+
* @param endpoint 请求端点
|
|
251
|
+
* @param method 请求方法
|
|
252
|
+
* @param data 请求数据
|
|
253
|
+
* @param needsAuth 是否需要认证
|
|
254
|
+
* @param additionalHeaders 附加请求头
|
|
255
|
+
* @param responseType 响应类型
|
|
256
|
+
* @returns 响应数据
|
|
257
|
+
*/
|
|
258
|
+
async handleUserTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType, baseUrl, userKey) {
|
|
259
|
+
// 用户模式:检查用户token状态
|
|
260
|
+
const tokenStatus = tokenCacheManager.checkUserTokenStatus(clientKey);
|
|
261
|
+
Logger.debug(`用户token状态:`, tokenStatus);
|
|
262
|
+
if (tokenStatus.canRefresh && !tokenStatus.isExpired) {
|
|
263
|
+
// 有有效的refresh_token,设置token为过期状态,让下次请求时刷新
|
|
264
|
+
Logger.info('用户模式:token过期,将在下次请求时刷新');
|
|
265
|
+
const tokenInfo = tokenCacheManager.getUserTokenInfo(clientKey);
|
|
266
|
+
if (tokenInfo) {
|
|
267
|
+
// 设置access_token为过期,但保留refresh_token
|
|
268
|
+
tokenInfo.expires_at = Math.floor(Date.now() / 1000) - 1;
|
|
269
|
+
tokenCacheManager.cacheUserToken(clientKey, tokenInfo);
|
|
270
|
+
}
|
|
271
|
+
// 重试请求
|
|
272
|
+
Logger.info('重试用户请求...');
|
|
273
|
+
return await this.request(endpoint, method, data, needsAuth, additionalHeaders, responseType, true);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// refresh_token已过期或不存在,直接清除缓存
|
|
277
|
+
Logger.warn('用户模式:refresh_token已过期,清除用户token缓存');
|
|
278
|
+
tokenCacheManager.removeUserToken(clientKey);
|
|
279
|
+
return this.handleAuthFailure(true, clientKey, baseUrl, userKey);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 生成用户授权URL
|
|
284
|
+
* @param baseUrl 基础URL
|
|
285
|
+
* @param userKey 用户键
|
|
286
|
+
* @returns 授权URL
|
|
287
|
+
*/
|
|
288
|
+
generateUserAuthUrl(baseUrl, userKey) {
|
|
289
|
+
const { appId, appSecret } = Config.getInstance().feishu;
|
|
290
|
+
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
291
|
+
const redirect_uri = `${baseUrl}/callback`;
|
|
292
|
+
const scope = encodeURIComponent('base:app:read bitable:app bitable:app:readonly board:whiteboard:node:read contact:user.employee_id:readonly docs:document.content:read docx:document docx:document.block:convert docx:document:create docx:document:readonly drive:drive drive:drive:readonly drive:file drive:file:upload sheets:spreadsheet sheets:spreadsheet:readonly space:document:retrieve space:folder:create wiki:space:read wiki:space:retrieve wiki:wiki wiki:wiki:readonly offline_access');
|
|
293
|
+
const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirect_uri);
|
|
294
|
+
return `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${scope}&state=${state}`;
|
|
295
|
+
}
|
|
212
296
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AuthService } from './feishuAuthService.js';
|
|
2
2
|
import { Config } from '../utils/config.js';
|
|
3
|
-
import { CacheManager } from '../utils/cache.js';
|
|
4
3
|
import { renderFeishuAuthResultHtml } from '../utils/document.js';
|
|
4
|
+
import { AuthUtils, TokenCacheManager } from '../utils/auth/index.js';
|
|
5
5
|
// 通用响应码
|
|
6
6
|
const CODE = {
|
|
7
7
|
SUCCESS: 0,
|
|
@@ -29,22 +29,34 @@ export async function callback(req, res) {
|
|
|
29
29
|
console.log('[callback] 缺少code参数');
|
|
30
30
|
return sendFail(res, '缺少code参数', CODE.PARAM_ERROR);
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const expectedClientKey = await CacheManager.getClientKey(client_id, client_secret);
|
|
36
|
-
if (state !== expectedClientKey) {
|
|
37
|
-
console.log('[callback] state(clientKey)不匹配');
|
|
38
|
-
return sendFail(res, 'state(clientKey)不匹配', CODE.PARAM_ERROR);
|
|
32
|
+
if (!state) {
|
|
33
|
+
console.log('[callback] 缺少state参数');
|
|
34
|
+
return sendFail(res, '缺少state参数', CODE.PARAM_ERROR);
|
|
39
35
|
}
|
|
40
|
-
|
|
36
|
+
// 解析state参数
|
|
37
|
+
const stateData = AuthUtils.decodeState(state);
|
|
38
|
+
if (!stateData) {
|
|
39
|
+
console.log('[callback] state参数解析失败');
|
|
40
|
+
return sendFail(res, 'state参数格式错误', CODE.PARAM_ERROR);
|
|
41
|
+
}
|
|
42
|
+
const { appId, appSecret, clientKey, redirectUri } = stateData;
|
|
43
|
+
console.log(`[callback] 解析state成功:`, { appId, clientKey, redirectUri });
|
|
44
|
+
// 验证state中的appId和appSecret是否与配置匹配
|
|
45
|
+
const configAppId = config.feishu.appId;
|
|
46
|
+
const configAppSecret = config.feishu.appSecret;
|
|
47
|
+
if (appId !== configAppId || appSecret !== configAppSecret) {
|
|
48
|
+
console.log('[callback] state中的appId或appSecret与配置不匹配');
|
|
49
|
+
return sendFail(res, 'state参数验证失败', CODE.PARAM_ERROR);
|
|
50
|
+
}
|
|
51
|
+
// 使用从state中解析的redirect_uri,如果没有则使用默认值
|
|
52
|
+
const redirect_uri = redirectUri || `http://localhost:${config.server.port}/callback`;
|
|
41
53
|
const session = req.session;
|
|
42
54
|
const code_verifier = session?.code_verifier || undefined;
|
|
43
55
|
try {
|
|
44
56
|
// 获取 user_access_token
|
|
45
57
|
const tokenResp = await authService.getUserTokenByCode({
|
|
46
|
-
client_id,
|
|
47
|
-
client_secret,
|
|
58
|
+
client_id: appId,
|
|
59
|
+
client_secret: appSecret,
|
|
48
60
|
code,
|
|
49
61
|
redirect_uri,
|
|
50
62
|
code_verifier
|
|
@@ -54,6 +66,19 @@ export async function callback(req, res) {
|
|
|
54
66
|
if (!data || data.code !== 0 || !data.access_token) {
|
|
55
67
|
return sendFail(res, `获取 access_token 失败,飞书返回: ${JSON.stringify(tokenResp)}`, CODE.CUSTOM);
|
|
56
68
|
}
|
|
69
|
+
// 使用TokenCacheManager缓存token信息
|
|
70
|
+
const tokenCacheManager = TokenCacheManager.getInstance();
|
|
71
|
+
if (data.access_token && data.expires_in) {
|
|
72
|
+
// 计算过期时间戳
|
|
73
|
+
data.expires_at = Math.floor(Date.now() / 1000) + data.expires_in;
|
|
74
|
+
if (data.refresh_token_expires_in) {
|
|
75
|
+
data.refresh_token_expires_at = Math.floor(Date.now() / 1000) + data.refresh_token_expires_in;
|
|
76
|
+
}
|
|
77
|
+
// 缓存token信息
|
|
78
|
+
const refreshTtl = data.refresh_token_expires_in || 3600 * 24 * 365; // 默认1年
|
|
79
|
+
tokenCacheManager.cacheUserToken(clientKey, data, refreshTtl);
|
|
80
|
+
console.log(`[callback] token已缓存到clientKey: ${clientKey}`);
|
|
81
|
+
}
|
|
57
82
|
// 获取用户信息
|
|
58
83
|
const access_token = data.access_token;
|
|
59
84
|
let userInfo = null;
|
|
@@ -61,20 +86,10 @@ export async function callback(req, res) {
|
|
|
61
86
|
userInfo = await authService.getUserInfo(access_token);
|
|
62
87
|
console.log('[callback] feishu userInfo:', userInfo);
|
|
63
88
|
}
|
|
64
|
-
return sendSuccess(res, { ...data, userInfo });
|
|
89
|
+
return sendSuccess(res, { ...data, userInfo, clientKey });
|
|
65
90
|
}
|
|
66
91
|
catch (e) {
|
|
67
92
|
console.error('[callback] 请求飞书token或用户信息失败:', e);
|
|
68
93
|
return sendFail(res, `请求飞书token或用户信息失败: ${e}`, CODE.CUSTOM);
|
|
69
94
|
}
|
|
70
95
|
}
|
|
71
|
-
export async function getTokenByParams({ client_id, client_secret, token_type }) {
|
|
72
|
-
const authService = new AuthService();
|
|
73
|
-
if (client_id)
|
|
74
|
-
authService.config.feishu.appId = client_id;
|
|
75
|
-
if (client_secret)
|
|
76
|
-
authService.config.feishu.appSecret = client_secret;
|
|
77
|
-
if (token_type)
|
|
78
|
-
authService.config.feishu.authType = token_type === 'user' ? 'user' : 'tenant';
|
|
79
|
-
return await authService.getToken();
|
|
80
|
-
}
|