feishu-mcp 0.1.1 → 0.1.3

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,7 +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
+ </a>
26
+
27
+ <a href="https://www.bilibili.com/video/BV18z3gzdE1w/?vd_source=94c14da5a71aeb01f665f159dd3d89c8">
28
+ <img src="image/demo_1.png" alt="飞书 MCP 使用演示" width="300"/>
25
29
  </a>
26
30
 
27
31
  > ⭐ **Star 本项目,第一时间获取最新功能和重要更新!** 关注项目可以让你不错过任何新特性、修复和优化,助你持续高效使用。你的支持也将帮助我们更好地完善和发展项目。⭐
@@ -44,13 +48,12 @@
44
48
  | **工具功能** | `convert_feishu_wiki_to_document_id` | Wiki链接转换 | 将Wiki链接转为文档ID | ✅ 已完成 |
45
49
  | | `get_feishu_image_resource` | 获取图片资源 | 下载文档中的图片 | ✅ 已完成 |
46
50
  | | `get_feishu_whiteboard_content` | 获取画板内容 | 获取画板中的图形元素和结构(流程图、思维导图等) | ✅ 已完成 |
47
- | **高级功能** | 表格操作 | 创建和编辑表格 | 结构化数据展示 | 🚧 计划中 |
48
- | | 图表插入 | 支持各类数据可视化图表 | 数据展示和分析 | 🚧 计划中 |
51
+ | **高级功能** | `create_feishu_table` | 创建和编辑表格 | 结构化数据展示 | 已完成 |
49
52
  | | 流程图插入 | 支持流程图和思维导图 | 流程梳理和可视化 | ✅ 已完成 |
50
53
  | 图片插入 | `upload_and_bind_image_to_block` | 支持插入本地和远程图片 | 修改文档内容 | ✅ 已完成 |
51
54
  | | 公式支持 | 支持数学公式 | 学术和技术文档 | ✅ 已完成 |
52
55
 
53
- ### 🎨 支持的样式功能
56
+ ### 🎨 支持的样式功能(基本支持md所有格式)
54
57
 
55
58
  - **文本样式**:粗体、斜体、下划线、删除线、行内代码
56
59
  - **文本颜色**:灰色、棕色、橙色、黄色、绿色、蓝色、紫色
@@ -61,6 +64,7 @@
61
64
  - **图片**:支持本地图片和网络图片
62
65
  - **公式**:在文本块中插入数学公式,支持LaTeX语法
63
66
  - **mermaid图表**:支持流程图、时序图、思维导图、类图、饼图等等
67
+ - **表格**:支持创建多行列表格,单元格可包含文本、标题、列表、代码块等多种内容类型
64
68
 
65
69
  ---
66
70
 
@@ -71,8 +75,10 @@
71
75
  - ~~**批量增强**:新增批量更新、批量图片上传,单次操作效率提升50%~~ 0.0.15 ✅
72
76
  - **流程优化**:减少多步调用,实现一键完成复杂任务
73
77
  - ~~**支持多种凭证类型**:包括 tenant_access_token和 user_access_token,满足不同场景下的认证需求~~ (飞书应用配置发生变更) 0.0.16 ✅。
74
- - **支持cursor用户登录**:方便在cursor平台用户认证
78
+ - ~~**支持cursor用户登录**:方便在cursor平台用户认证 不做了,没必要 ❌~~
75
79
  - ~~**支持mermaid图表**:流程图、时序图等等,丰富文档内容~~ 0.1.11 ✅
80
+ - ~~**支持表格创建**:创建包含各种块类型的复杂表格,支持样式控制~~ 0.1.2 ✅
81
+ - ~~**支持飞书多用户user认证**:一人部署,可以多人使用~~ 0.1.3 ✅
76
82
 
77
83
  ---
78
84
 
@@ -91,29 +97,10 @@
91
97
  ### 方式一:使用 NPM 快速运行
92
98
 
93
99
  ```bash
94
- 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>
95
101
  ```
96
102
 
97
- ### 方式二:使用 Smithery 平台
98
-
99
- **已发布到 Smithery 平台,可访问:** https://smithery.ai/server/@cso1z/feishu-mcp
100
-
101
- ### 方式三:本地运行
102
-
103
-
104
- #### 🌿 分支说明
105
-
106
- 本项目采用主分支(main)+功能分支(feature/xxx)协作模式:
107
-
108
- - **main**
109
- 稳定主线分支,始终保持可用、可部署状态。所有已验证和正式发布的功能都会合并到 main 分支。
110
-
111
- - **multi-user-token**
112
-
113
- 多用户隔离与按用户授权的 Feishu Token 获取功能开发分支。该分支支持 userKey 参数、按用户获取和缓存 Token、自定义 Token 服务等高级特性,适用于需要多用户隔离和授权场景的开发与测试。
114
- > ⚠️ 该分支为 beta 版本,功能更新相较 main 分支可能会有延后。如有相关需求请在 issue 区留言,我会优先同步最新功能到该分支。
115
-
116
-
103
+ ### 方式二:本地运行
117
104
  1. **克隆仓库**
118
105
  ```bash
119
106
  git clone https://github.com/cso1z/Feishu-MCP.git
@@ -126,16 +113,6 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
126
113
  ```
127
114
 
128
115
  3. **配置环境变量(复制一份.env.example保存为.env文件)**
129
-
130
- **macOS/Linux:**
131
- ```bash
132
- cp .env.example .env
133
- ```
134
-
135
- **Windows:**
136
- ```cmd
137
- copy .env.example .env
138
- ```
139
116
 
140
117
  4. **编辑 .env 文件**
141
118
  在项目根目录下找到并用任意文本编辑器打开 `.env` 文件,填写你的飞书应用凭证:
@@ -143,6 +120,7 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
143
120
  FEISHU_APP_ID=cli_xxxxx
144
121
  FEISHU_APP_SECRET=xxxxx
145
122
  PORT=3333
123
+ FEISHU_AUTH_TYPE=tenant/user
146
124
  ```
147
125
 
148
126
  5. **运行服务器**
@@ -154,32 +132,12 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
154
132
 
155
133
  ### 环境变量配置
156
134
 
157
- | 变量名 | 必需 | 描述 | 默认值 |
158
- |--------|------|-----------------------------------------------|-------|
159
- | `FEISHU_APP_ID` | ✅ | 飞书应用 ID | - |
160
- | `FEISHU_APP_SECRET` | ✅ | 飞书应用密钥 | - |
161
- | `PORT` | ❌ | 服务器端口 | `3333` |
162
- | `FEISHU_AUTH_TYPE` | ❌ | 认证凭证类型,建议本地运行时使用 `user`(用户级,需OAuth授权),云端/生产环境使用 `tenant`(应用级,默认) | `tenant` |
163
- | `FEISHU_TOKEN_ENDPOINT` | ❌ | 获取 token 的接口地址,仅当自定义 token 管理时需要 | `http://localhost:3333/getToken` |
164
-
165
- > **注意:**
166
- > - 只有本地运行服务时支持 `user` 凭证,否则需配置 `FEISHU_TOKEN_ENDPOINT`,自行实现 token 获取与管理(可参考 `callbackService`、`feishuAuthService`)。
167
- > - `FEISHU_TOKEN_ENDPOINT` 接口参数:`client_id`, `client_secret`, `token_type`(可选,tenant/user);返回参数:`access_token`, `needAuth`, `url`(需授权时), `expires_in`(单位:s)。
168
-
169
- ### 命令行参数
170
-
171
- | 参数 | 描述 | 默认值 |
172
- |------|------|-------|
173
- | `--port` | 服务器监听端口 | `3333` |
174
- | `--log-level` | 日志级别 (debug/info/log/warn/error/none) | `info` |
175
- | `--feishu-app-id` | 飞书应用 ID | - |
176
- | `--feishu-app-secret` | 飞书应用密钥 | - |
177
- | `--feishu-base-url` | 飞书API基础URL | `https://open.feishu.cn/open-apis` |
178
- | `--cache-enabled` | 是否启用缓存 | `true` |
179
- | `--cache-ttl` | 缓存生存时间(秒) | `3600` |
180
- | `--stdio` | 命令模式运行 | - |
181
- | `--help` | 显示帮助菜单 | - |
182
- | `--version` | 显示版本号 | - |
135
+ | 变量名 | 必需 | 描述 | 默认值 |
136
+ |--------|------|--------------------------------------------------------------------|-------|
137
+ | `FEISHU_APP_ID` | ✅ | 飞书应用 ID | - |
138
+ | `FEISHU_APP_SECRET` | ✅ | 飞书应用密钥 | - |
139
+ | `PORT` | ❌ | 服务器端口 | `3333` |
140
+ | `FEISHU_AUTH_TYPE` | ❌ | 认证凭证类型,使用 `user`(用户级,使用时是用户的身份操作飞书文档,需OAuth授权),使用 `tenant`(应用级,默认) | `tenant` |
183
141
 
184
142
  ### 配置文件方式(适用于 Cursor、Cline 等)
185
143
 
@@ -191,11 +149,12 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
191
149
  "args": ["-y", "feishu-mcp", "--stdio"],
192
150
  "env": {
193
151
  "FEISHU_APP_ID": "<你的飞书应用ID>",
194
- "FEISHU_APP_SECRET": "<你的飞书应用密钥>"
152
+ "FEISHU_APP_SECRET": "<你的飞书应用密钥>",
153
+ "FEISHU_AUTH_TYPE": "<tenant/user>"
195
154
  }
196
155
  },
197
156
  "feishu_local": {
198
- "url": "http://localhost:3333/sse"
157
+ "url": "http://localhost:3333/sse?:userKey=123456"
199
158
  }
200
159
  }
201
160
  }
@@ -216,6 +175,8 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
216
175
  3. ### **公式使用说明**:
217
176
  在文本块中可以混合使用普通文本和公式元素。公式使用LaTeX语法,如:`1+2=3`、`\frac{a}{b}`、`\sqrt{x}`等。支持在同一文本块中包含多个公式和普通文本。
218
177
 
178
+ 4. ### **使用飞书user认证**:
179
+ user认证与tenant认证在增加权限时是有区分的,所以**在初次由tenant切换到user时需要注意配置的权限**;为了区分不同的用户需要在配置mcp server服务的url增加query参数:userKey,**该值是用户的唯一标识 所以最好在设置时越随机越好**
219
180
  ---
220
181
 
221
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.0",
9
+ version: "0.1.3",
10
10
  };
11
11
  const serverOptions = {
12
12
  capabilities: { logging: {}, tools: {} },
@@ -8,11 +8,11 @@ import { DocumentIdSchema, ParentBlockIdSchema, BlockIdSchema, IndexSchema, Star
8
8
  TextElementsArraySchema,
9
9
  // CodeLanguageSchema,
10
10
  // CodeWrapSchema,
11
- BlockConfigSchema, MediaIdSchema, MediaExtraSchema, ImagesArraySchema,
11
+ BlockConfigSchema, MediaIdSchema, MediaExtraSchema, ImagesArraySchema,
12
12
  // MermaidCodeSchema,
13
13
  // ImageWidthSchema,
14
14
  // ImageHeightSchema
15
- } from '../../types/feishuSchema.js';
15
+ TableCreateSchema } from '../../types/feishuSchema.js';
16
16
  /**
17
17
  * 注册飞书块相关的MCP工具
18
18
  * @param server MCP服务器实例
@@ -618,4 +618,45 @@ export function registerFeishuBlockTools(server, feishuService) {
618
618
  };
619
619
  }
620
620
  });
621
+ // 添加创建飞书表格工具
622
+ server.tool('create_feishu_table', 'Creates a table block in a Feishu document with specified rows and columns. Each cell can contain different types of content blocks (text, lists, code, etc.). This tool creates the complete table structure including table cells and their content. Note: For Feishu wiki links (https://xxx.feishu.cn/wiki/xxx) you must first use convert_feishu_wiki_to_document_id tool to obtain a compatible document ID.', {
623
+ documentId: DocumentIdSchema,
624
+ parentBlockId: ParentBlockIdSchema,
625
+ index: IndexSchema,
626
+ tableConfig: TableCreateSchema,
627
+ }, async ({ documentId, parentBlockId, index = 0, tableConfig }) => {
628
+ try {
629
+ if (!feishuService) {
630
+ return {
631
+ content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }],
632
+ };
633
+ }
634
+ Logger.info(`开始创建飞书表格,文档ID: ${documentId},父块ID: ${parentBlockId},表格大小: ${tableConfig.rowSize}x${tableConfig.columnSize},插入位置: ${index}`);
635
+ const result = await feishuService.createTableBlock(documentId, parentBlockId, tableConfig, index);
636
+ // 构建返回信息
637
+ let resultText = `表格创建成功!\n\n表格大小: ${tableConfig.rowSize}x${tableConfig.columnSize}\n`;
638
+ // 如果有图片token,显示图片信息
639
+ if (result.imageTokens && result.imageTokens.length > 0) {
640
+ resultText += `\n\n📸 发现 ${result.imageTokens.length} 个图片:\n`;
641
+ result.imageTokens.forEach((imageToken, index) => {
642
+ resultText += `${index + 1}. 坐标(${imageToken.row}, ${imageToken.column}) - blockId: ${imageToken.blockId}\n`;
643
+ });
644
+ resultText += "你需要使用upload_and_bind_image_to_block工具绑定图片";
645
+ }
646
+ resultText += `\n\n完整结果:\n${JSON.stringify(result, null, 2)}`;
647
+ return {
648
+ content: [{
649
+ type: 'text',
650
+ text: resultText
651
+ }],
652
+ };
653
+ }
654
+ catch (error) {
655
+ Logger.error(`创建飞书表格失败:`, error);
656
+ const errorMessage = formatErrorMessage(error);
657
+ return {
658
+ content: [{ type: 'text', text: `创建飞书表格失败: ${errorMessage}` }],
659
+ };
660
+ }
661
+ });
621
662
  }
@@ -107,11 +107,14 @@ export function registerFeishuTools(server, feishuService) {
107
107
  // 检查是否有 block_type 为 43 的块(画板块)
108
108
  const whiteboardBlocks = blocks.filter((block) => block.block_type === 43);
109
109
  const hasWhiteboardBlocks = whiteboardBlocks.length > 0;
110
+ // 检查是否有 block_type 为 27 的块(图片块)
111
+ const imageBlocks = blocks.filter((block) => block.block_type === 27);
112
+ const hasImageBlocks = imageBlocks.length > 0;
110
113
  let responseText = JSON.stringify(blocks, null, 2);
111
114
  if (hasWhiteboardBlocks) {
112
115
  responseText += '\n\n⚠️ 检测到画板块 (block_type: 43)!\n';
113
- responseText += `发现 ${whiteboardBlocks.length} 个画板块。画板块包含丰富的图形内容,如形状、文本、思维导图等。\n`;
114
- responseText += '建议使用 get_feishu_whiteboard_content 工具来获取画板的具体内容和结构。\n';
116
+ responseText += `发现 ${whiteboardBlocks.length} 个画板块。\n`;
117
+ responseText += '💡 提示:如果您需要获取画板的具体内容(如流程图、思维导图等),可以使用 get_feishu_whiteboard_content 工具。\n';
115
118
  responseText += '画板信息:\n';
116
119
  whiteboardBlocks.forEach((block, index) => {
117
120
  responseText += ` ${index + 1}. 块ID: ${block.block_id}`;
@@ -120,7 +123,21 @@ export function registerFeishuTools(server, feishuService) {
120
123
  }
121
124
  responseText += '\n';
122
125
  });
123
- responseText += '请使用上述画板ID调用 get_feishu_whiteboard_content 工具。';
126
+ responseText += '📝 注意:只有在需要分析画板内容时才调用上述工具,仅了解文档结构时无需获取。';
127
+ }
128
+ if (hasImageBlocks) {
129
+ responseText += '\n\n🖼️ 检测到图片块 (block_type: 27)!\n';
130
+ responseText += `发现 ${imageBlocks.length} 个图片块。\n`;
131
+ responseText += '💡 提示:如果您需要查看图片的具体内容,可以使用 get_feishu_image_resource 工具下载图片。\n';
132
+ responseText += '图片信息:\n';
133
+ imageBlocks.forEach((block, index) => {
134
+ responseText += ` ${index + 1}. 块ID: ${block.block_id}`;
135
+ if (block.image && block.image.token) {
136
+ responseText += `, 媒体ID: ${block.image.token}`;
137
+ }
138
+ responseText += '\n';
139
+ });
140
+ responseText += '📝 注意:只有在需要查看图片内容时才调用上述工具,仅了解文档结构时无需获取。';
124
141
  }
125
142
  return {
126
143
  content: [{ type: 'text', text: responseText }],
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';
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,26 +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';
4
+ import { formatErrorMessage, AuthRequiredError } from '../utils/error.js';
5
+ import { Config } from '../utils/config.js';
6
+ import { TokenCacheManager, UserContextManager, AuthUtils } from '../utils/auth';
5
7
  /**
6
8
  * API服务基类
7
9
  * 提供通用的HTTP请求处理和认证功能
8
10
  */
9
11
  export class BaseApiService {
10
- constructor() {
11
- Object.defineProperty(this, "accessToken", {
12
- enumerable: true,
13
- configurable: true,
14
- writable: true,
15
- value: ''
16
- });
17
- Object.defineProperty(this, "tokenExpireTime", {
18
- enumerable: true,
19
- configurable: true,
20
- writable: true,
21
- value: null
22
- });
23
- }
24
12
  /**
25
13
  * 处理API错误
26
14
  * @param error 错误对象
@@ -66,9 +54,16 @@ export class BaseApiService {
66
54
  * @param needsAuth 是否需要认证
67
55
  * @param additionalHeaders 附加请求头
68
56
  * @param responseType 响应类型
57
+ * @param retry 是否允许重试,默认为false
69
58
  * @returns 响应数据
70
59
  */
71
- 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}`);
72
67
  try {
73
68
  // 构建请求URL
74
69
  const url = `${this.getBaseUrl()}${endpoint}`;
@@ -86,7 +81,7 @@ export class BaseApiService {
86
81
  }
87
82
  // 添加认证令牌
88
83
  if (needsAuth) {
89
- const accessToken = await this.getAccessToken();
84
+ const accessToken = await this.getAccessToken(userKey);
90
85
  headers['Authorization'] = `Bearer ${accessToken}`;
91
86
  }
92
87
  // 记录请求信息
@@ -130,25 +125,26 @@ export class BaseApiService {
130
125
  return response.data.data;
131
126
  }
132
127
  catch (error) {
133
- // 处理401错误,可能是令牌过期
134
- if (error instanceof AxiosError && error.response?.status === 401) {
135
- // 清除当前令牌,下次请求会重新获取
136
- this.accessToken = '';
137
- this.tokenExpireTime = null;
138
- Logger.warn('访问令牌可能已过期,已清除缓存的令牌');
139
- // 如果这是重试请求,避免无限循环
140
- if (error.isRetry) {
141
- 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);
142
141
  }
143
- // 重试请求
144
- Logger.info('重试请求...');
145
- try {
146
- 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);
147
145
  }
148
- catch (retryError) {
149
- // 标记为重试请求
150
- retryError.isRetry = true;
151
- this.handleApiError(retryError, `重试API请求失败 (${endpoint})`);
146
+ else {
147
+ return this.handleUserTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType, baseUrl, userKey);
152
148
  }
153
149
  }
154
150
  // 处理其他错误
@@ -205,4 +201,96 @@ export class BaseApiService {
205
201
  async delete(endpoint, data, needsAuth = true) {
206
202
  return this.request(endpoint, 'DELETE', data, needsAuth);
207
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
+ }
208
296
  }