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 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="800"/>
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="800"/>
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
- - **支持cursor用户登录**:方便在cursor平台用户认证
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
- ### 方式二:使用 Smithery 平台
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` | ❌ | 服务器端口 | `3333` |
167
- | `FEISHU_AUTH_TYPE` | ❌ | 认证凭证类型,建议本地运行时使用 `user`(用户级,需OAuth授权),云端/生产环境使用 `tenant`(应用级,默认) | `tenant` |
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
  ## 🚨 故障排查
@@ -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.2",
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, getTokenByParams } from './services/callbackService.js';
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
- Logger.log(`[SSE Connection] New SSE connection established for sessionId ${sessionId} params:${JSON.stringify(req.params)} headers:${JSON.stringify(req.headers)} `);
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
- Logger.info(`[SSE messages] Received message with sessionId: ${sessionId}, params: ${JSON.stringify(req.query)}, body: ${JSON.stringify(req.body)}`);
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
- await transport.handlePostMessage(req, res);
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
- // 处理401错误,可能是令牌过期
136
- if (error instanceof AxiosError && error.response?.status === 401) {
137
- // 清除当前令牌,下次请求会重新获取
138
- this.accessToken = '';
139
- this.tokenExpireTime = null;
140
- Logger.warn(`访问令牌可能已过期,已清除缓存的令牌`);
141
- const clientKey = await CacheManager.getClientKey(Config.getInstance().feishu.appId, Config.getInstance().feishu.appSecret);
142
- CacheManager.getInstance().removeUserToken(clientKey);
143
- // 如果这是重试请求,避免无限循环
144
- if (error.isRetry) {
145
- this.handleApiError(error, `API请求失败 (${endpoint})`);
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
- Logger.info('重试请求...');
149
- try {
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
- catch (retryError) {
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
- // 校验state(clientKey)
33
- const client_id = config.feishu.appId;
34
- const client_secret = config.feishu.appSecret;
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
- const redirect_uri = `http://localhost:${config.server.port}/callback`;
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
- }