@lark-apaas/coding-steering 0.1.6-alpha.8 → 0.1.6-alpha.9

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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/steering/design-stack/skills/.gitkeep +0 -0
  3. package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +174 -0
  4. package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +621 -0
  5. package/steering/nestjs-react-fullstack/skills/authz-guide/references/management-page-spec.md +505 -0
  6. package/steering/nestjs-react-fullstack/skills/authz-guide/references/runtime-role-controller-spec.md +203 -0
  7. package/steering/nestjs-react-fullstack/skills/authz-guide/references/sdk-examples.md +90 -0
  8. package/steering/nestjs-react-fullstack/skills/authz-guide/references/sdk-types.md +216 -0
  9. package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +405 -0
  10. package/steering/nestjs-react-fullstack/skills/devops-guide/SKILL.md +119 -0
  11. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +582 -0
  12. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +357 -0
  13. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +513 -0
  14. package/steering/nestjs-react-fullstack/skills/react-hook-best-practices/SKILL.md +118 -0
  15. package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +177 -0
  16. package/steering/nestjs-react-fullstack/skills/user-management-best-practices/SKILL.md +142 -0
  17. package/steering/design-stack/skills/client-add-aily-web-chat/SKILL.md +0 -139
  18. package/steering/design-stack/skills/client-builtins-user-service/SKILL.md +0 -628
  19. package/steering/design-stack/skills/code-fix/SKILL.md +0 -246
  20. package/steering/design-stack/skills/feishu/SKILL.md +0 -270
  21. package/steering/design-stack/skills/feishu/references/approval.md +0 -214
  22. package/steering/design-stack/skills/feishu/references/attendance.md +0 -163
  23. package/steering/design-stack/skills/feishu/references/bitable.md +0 -309
  24. package/steering/design-stack/skills/feishu/references/calendar.md +0 -190
  25. package/steering/design-stack/skills/feishu/references/contacts.md +0 -160
  26. package/steering/design-stack/skills/feishu/references/doc.md +0 -256
  27. package/steering/design-stack/skills/feishu/references/drive.md +0 -103
  28. package/steering/design-stack/skills/feishu/references/events.md +0 -198
  29. package/steering/design-stack/skills/feishu/references/id-convert.md +0 -128
  30. package/steering/design-stack/skills/feishu/references/messaging.md +0 -207
  31. package/steering/design-stack/skills/feishu/references/oauth.md +0 -164
  32. package/steering/design-stack/skills/feishu/references/perm.md +0 -90
  33. package/steering/design-stack/skills/feishu/references/wiki.md +0 -164
  34. package/steering/design-stack/skills/user-identity/SKILL.md +0 -300
@@ -1,198 +0,0 @@
1
- # 事件订阅 (Events)
2
-
3
- > 开放平台文档(Markdown 版):https://open.larkoffice.com/document/server-docs/event-subscription-guide/overview
4
-
5
- 通过 WebSocket 长连接接收飞书事件推送,无需公网 IP / 域名,无需加解密。
6
-
7
- ## WebSocket 长连接(推荐)
8
-
9
- 使用 NestJS `OnModuleInit` 生命周期钩子启动 WebSocket 长连接:
10
-
11
- ```typescript
12
- import { Injectable, OnModuleInit } from '@nestjs/common';
13
- import * as lark from '@larksuiteoapi/node-sdk';
14
-
15
- @Injectable()
16
- export class FeishuEventService implements OnModuleInit {
17
- private readonly wsClient: lark.WSClient;
18
-
19
- constructor(private readonly feishuService: FeishuService) {
20
- const client = this.feishuService.getClient();
21
- this.wsClient = new lark.WSClient({
22
- appId: FEISHU_APP_ID,
23
- appSecret: FEISHU_APP_SECRET,
24
- loggerLevel: lark.LoggerLevel.info,
25
- });
26
- }
27
-
28
- onModuleInit() {
29
- this.wsClient.start({
30
- eventDispatcher: new lark.EventDispatcher({}).register({
31
- 'im.message.receive_v1': async (data) => {
32
- // 处理收到的消息事件
33
- },
34
- }),
35
- });
36
- }
37
- }
38
- ```
39
-
40
- ## 消息卡片回调(card.action.trigger)
41
-
42
- 用户点击卡片按钮、选择下拉项等交互操作会触发 `card.action.trigger` 回调,通过长连接接收,在 `EventDispatcher.register()` 中注册处理函数即可。
43
-
44
- > **前置配置**:开发者后台 → 事件与回调 → 订阅方式 → 选择「**使用长连接接收事件/回调**」(而非 HTTP 回调地址)
45
-
46
- ```typescript
47
- import { Injectable, OnModuleInit } from '@nestjs/common';
48
- import * as lark from '@larksuiteoapi/node-sdk';
49
-
50
- @Injectable()
51
- export class FeishuEventService implements OnModuleInit {
52
- private readonly wsClient: lark.WSClient;
53
-
54
- constructor(private readonly feishuService: FeishuService) {
55
- this.wsClient = new lark.WSClient({
56
- appId: FEISHU_APP_ID,
57
- appSecret: FEISHU_APP_SECRET,
58
- loggerLevel: lark.LoggerLevel.info,
59
- });
60
- }
61
-
62
- onModuleInit() {
63
- this.wsClient.start({
64
- eventDispatcher: new lark.EventDispatcher({}).register({
65
- // 普通消息事件
66
- 'im.message.receive_v1': async (data) => {
67
- // 处理收到的消息事件
68
- },
69
-
70
- // 消息卡片交互回调
71
- 'card.action.trigger': async (data) => {
72
- const {
73
- action, // 用户触发的交互动作
74
- operator, // 操作者信息
75
- context, // 卡片上下文
76
- } = data;
77
-
78
- // action.value — 交互元素配置的 value(JSON 对象)
79
- // action.tag — 触发的组件类型:'button' | 'select_static' | 'date_picker' 等
80
- // action.option — 下拉选框选中的值(select_static 专用)
81
- // action.form_value — 表单容器提交数据(Record<string, string | string[]>)
82
- // action.name — 用户操作的交互组件名称(开发者自定义)
83
- // operator.open_id — 操作者 open_id(直接字段,非嵌套)
84
- // operator.user_id — 操作者 user_id(字符串)
85
- // operator.union_id — 操作者 union_id
86
- // context.open_message_id — 卡片消息 ID(可用于 patch 更新)
87
- // context.open_chat_id — 所在会话 ID
88
-
89
- // 返回新卡片内容可直接更新该卡片(返回 undefined 则不更新)
90
- // 响应体为 v2 格式:{ toast?, card: { type: 'raw', data: { schema: '2.0', ... } } }
91
- if (action.value?.confirm) {
92
- return {
93
- toast: {
94
- type: 'success',
95
- content: '操作已完成',
96
- i18n: { zh_cn: '操作已完成', en_us: 'Done' },
97
- },
98
- card: {
99
- type: 'raw',
100
- data: {
101
- schema: '2.0',
102
- config: { update_multi: true },
103
- header: {
104
- template: 'green',
105
- title: { content: '已确认', tag: 'plain_text' },
106
- },
107
- body: {
108
- direction: 'vertical',
109
- elements: [
110
- { tag: 'markdown', content: '操作已完成 ✅' },
111
- ],
112
- },
113
- },
114
- },
115
- };
116
- }
117
- },
118
- }),
119
- });
120
- }
121
- }
122
- ```
123
-
124
- ### card.action.trigger 事件数据结构
125
-
126
- 长连接模式下事件为 v2 格式,`EventDispatcher` 解析后将 `header` 和 `event` 字段平铺到 `data` 中:
127
-
128
- | 字段 | 类型 | 说明 |
129
- |------|------|------|
130
- | `action.value` | `Record<string, any>` | 交互元素配置的 value 数据 |
131
- | `action.tag` | `string` | 组件类型:`button` / `select_static` / `date_picker` 等 |
132
- | `action.option` | `string?` | 下拉选框选中项(select_static 触发时有值) |
133
- | `action.timezone` | `string?` | 时区(日期选择器触发时有值) |
134
- | `action.form_value` | `Record<string, string \| string[]>?` | 表单容器提交数据(form 组件触发时有值) |
135
- | `action.name` | `string?` | 用户操作的交互组件名称(开发者在组件上自定义) |
136
- | `operator.open_id` | `string` | 操作者 open_id(直接字段,非嵌套) |
137
- | `operator.user_id` | `string?` | 操作者 user_id |
138
- | `operator.union_id` | `string?` | 操作者 union_id |
139
- | `operator.tenant_key` | `string?` | 操作者所在租户 key |
140
- | `context.open_message_id` | `string` | 卡片所在消息 ID |
141
- | `context.open_chat_id` | `string` | 卡片所在会话 ID |
142
- | `token` | `string` | 回调 token(验签用,长连接模式无需手动验签) |
143
- | `host` | `string` | 宿主环境,如 `im_message` |
144
-
145
- ### card.action.trigger 响应体结构(v2 格式)
146
-
147
- 处理函数 return 的对象会作为响应体直接更新卡片,必须使用 v2 格式:
148
-
149
- ```json
150
- {
151
- "toast": {
152
- "type": "info",
153
- "content": "提示内容",
154
- "i18n": { "zh_cn": "提示内容", "en_us": "Message" }
155
- },
156
- "card": {
157
- "type": "raw",
158
- "data": {
159
- "schema": "2.0",
160
- "config": { "update_multi": true },
161
- "header": {
162
- "title": { "tag": "plain_text", "content": "标题" },
163
- "template": "blue"
164
- },
165
- "body": {
166
- "direction": "vertical",
167
- "elements": [
168
- { "tag": "markdown", "content": "卡片内容" }
169
- ]
170
- }
171
- }
172
- }
173
- }
174
- ```
175
-
176
- | 字段 | 必需 | 说明 |
177
- |------|------|------|
178
- | `toast` | 否 | 在用户侧显示浮层提示;`type` 可选 `info` / `success` / `error` / `warning` |
179
- | `card` | 否 | 更新后的卡片内容;不返回则卡片保持不变 |
180
- | `card.type` | 是 | 固定为 `"raw"` |
181
- | `card.data.schema` | 是 | 固定为 `"2.0"` |
182
- | `card.data.body.elements` | 是 | 卡片元素列表(嵌套在 `body` 中,非根级) |
183
-
184
- > **与旧版区别**:v1 格式直接在根级放 `elements`;v2 格式必须将元素放在 `card.data.body.elements` 中,且外层需要 `card.type: "raw"` 包装。
185
-
186
- ### 卡片更新方式对比
187
-
188
- | 方式 | 说明 |
189
- |------|------|
190
- | 处理函数直接 return 卡片 | 即时更新触发交互的卡片(推荐,3 秒内处理完成) |
191
- | `client.im.message.patch()` | 异步更新,适合耗时操作(需在另一协程/队列中处理) |
192
-
193
- ## 当前不支持
194
-
195
- 以下模式需要飞书通过 HTTP 回调应用服务,当前环境暂不支持:
196
-
197
- - Webhook 事件订阅(HTTP 模式)— 使用上方 WebSocket 长连接代替
198
- - HTTP 模式 CardActionHandler — 使用长连接 `card.action.trigger` 代替(见上方)
@@ -1,128 +0,0 @@
1
- # 妙搭与飞书开放平台 — ID 识别与转换
2
-
3
- > 开放平台文档(Markdown 版):https://open.larkoffice.com/document/uAjLw4CM/ukTMukTMukTM/spark-v1/overview.md
4
- >
5
- > 飞书用户身份体系官方说明:https://open.larkoffice.com/document/platform-overveiw/basic-concepts/user-identity-introduction/introduction
6
- >
7
- > **承接 `user-identity` skill 决策树**:如果只需 妙搭 userId → 飞书 user_id(employee_id),在 nestjs-react-fullstack 项目内优先用 `AuthNPaasService`。本文覆盖 `AuthNPaasService` 不支持的场景:需要 `open_id` / `union_id`、或需要任何方向的反查、或不在该模板内的项目。
8
-
9
- ## ID 识别速查
10
-
11
- 拿到一个 ID 后,先判断它属于哪个体系:
12
-
13
- | ID 来源 | 获取方式 | 格式特征 | 说明 |
14
- |---------|---------|---------|------|
15
- | 妙搭 userId | 后端 `req.userContext.userId`、前端 `useCurrentUserProfile().user_id` | 纯数字字符串 | **两者是同一个 ID**(dataloom 底层同源) |
16
- | 妙搭 tenantId | `req.userContext.tenantId` | 纯数字字符串 | 租户/工作区标识 |
17
- | 妙搭 appId | `req.userContext.appId` | 纯数字字符串 | 当前应用标识 |
18
- | 飞书 open_id | 飞书开放平台 API 返回 | `ou_` 开头 | 应用级,同一用户在不同应用中 open_id 不同 |
19
- | 飞书 union_id | 飞书开放平台 API 返回 | `on_` 开头 | 开发者级,同一开发者下的多个应用中相同 |
20
- | 飞书 user_id(即 `employee_id`) | 飞书通讯录 API 返回 / `AuthNPaasService` 返回 | 无固定前缀 | 企业级,用户在企业内的身份标识,全企业唯一 |
21
-
22
- > 官方定义:`user_id` 也称为 `employee_id`,两者在多数业务场景等价(除飞书招聘)。它**不是**员工工号(`employee_no` 是另一个独立字段)。
23
- >
24
- > 飞书还有第四种 ID `lark_id`(跨企业全局物理身份),仅用于跨企业场景,应用开发流程中基本不接触,本文不涉及。
25
-
26
- ## 决策树:我需要转换吗?
27
-
28
- ```
29
- 拿到的 ID 是什么?
30
- ├─ 妙搭 userId(纯数字)
31
- │ ├─ nestjs-react-fullstack 项目内要拿飞书 user_id → 用 AuthNPaasService(见 user-identity skill),单向
32
- │ ├─ 要调飞书开放平台 API 拿 open_id/union_id → 用下方 spark id_convert API
33
- │ └─ 仅妙搭内部使用 → 直接用,不需要转换
34
- ├─ 飞书 open_id(ou_ 开头)→ 要在妙搭中查用户?
35
- │ ├─ 是 → 转换为妙搭 userId(用下方 spark id_convert API type 20)
36
- │ └─ 否(仅飞书 API 使用)→ 直接用
37
- ├─ 飞书 union_id(on_ 开头)→ 要在妙搭中查用户?
38
- │ ├─ 是 → 转换为妙搭 userId(用下方 spark id_convert API type 21)
39
- │ └─ 否 → 直接用
40
- └─ 飞书 user_id(即 employee_id)
41
- ├─ 妙搭 userId → 飞书 user_id:nestjs-react-fullstack 项目内用 AuthNPaasService(仅此一种方法)
42
- └─ 飞书 user_id → 妙搭 userId:无单步方案,必须两步走(见下方"反向:飞书 user_id → 妙搭 userId")
43
- ```
44
-
45
- ### 反向:飞书 user_id → 妙搭 userId(两步走)
46
-
47
- `spark id_convert` 不接受 `user_id` 作为输入;`AuthNPaasService` 也没有反向方法。要把飞书 user_id 转回妙搭 userId,必须分两步:
48
-
49
- ```typescript
50
- // Step 1: 飞书通讯录 API,用 user_id 查询用户,从响应里读 open_id
51
- // 需要权限 contact:contact.base:readonly
52
- const userRes = await client.contact.user.get({
53
- path: { user_id: '<飞书 user_id / employee_id>' },
54
- params: { user_id_type: 'user_id' }, // ← 告诉接口"我传入的是 user_id 类型"
55
- });
56
- if (userRes.code !== 0) throw new Error(`contact.user.get failed: ${userRes.msg}`);
57
- const openId = userRes.data?.user?.open_id;
58
- if (!openId) throw new Error('open_id missing in contact.user.get response');
59
-
60
- // Step 2: spark id_convert type 20,open_id → 妙搭 userId
61
- // 需要权限"获取 ID 转换信息"
62
- type IdConvertResp = { items: { source_id: string; target_id: string }[] };
63
- const convRes = await client.request<IdConvertResp>({
64
- method: 'POST',
65
- url: '/open-apis/spark/v1/directory/user/id_convert',
66
- data: { id_convert_type: 20, ids: [openId] },
67
- });
68
- if (convRes.code !== 0) throw new Error(`id_convert failed: ${convRes.msg}`);
69
- const miaodaUserId = convRes.data?.items?.[0]?.target_id;
70
- ```
71
-
72
- > 批量场景同理:先调通讯录批量接口(`GET /open-apis/contact/v3/users/batch`,参数 `user_ids[]` + `user_id_type=user_id`,可通过 `client.request({ method: 'GET', url: '...', params: {...} })` 直接调)拿一组 `open_id`,再传给 spark id_convert(最多 100 个)。**响应里用 `source_id` 字段匹配回原 user_id**,不要按下标对位,飞书不保证返回顺序与请求一致。
73
-
74
- ## 转换 API
75
-
76
- ### 所需权限
77
-
78
- | 权限标识 | 说明 |
79
- |----------|------|
80
- | `获取 ID 转换信息` | spark 通讯录 ID 转换权限(需在开发者后台申请) |
81
-
82
- ### 转换类型
83
-
84
- | id_convert_type | 方向 |
85
- |-----------------|------|
86
- | `10` | 妙搭 userId → 飞书 open_id |
87
- | `11` | 妙搭 userId → 飞书 union_id |
88
- | `20` | 飞书 open_id → 妙搭 userId |
89
- | `21` | 飞书 union_id → 妙搭 userId |
90
-
91
- ### 调用示例
92
-
93
- ```typescript
94
- // 妙搭 userId → 飞书 open_id
95
- const res = await client.request({
96
- method: 'POST',
97
- url: '/open-apis/spark/v1/directory/user/id_convert',
98
- data: {
99
- id_convert_type: 10,
100
- ids: ['123456789837364'], // 妙搭 userId 列表,最多 100 个
101
- },
102
- });
103
- // res.data.items — [{ source_id: '123456789837364', target_id: 'ou_1234cdjhjfedgfhgdhy3884' }]
104
-
105
- // 飞书 open_id → 妙搭 userId
106
- const res2 = await client.request({
107
- method: 'POST',
108
- url: '/open-apis/spark/v1/directory/user/id_convert',
109
- data: {
110
- id_convert_type: 20,
111
- ids: ['ou_1234cdjhjfedgfhgdhy3884'],
112
- },
113
- });
114
- ```
115
-
116
- > 频率限制 50 次/秒,批量最多 100 个 ID。优先批量调用,避免逐个转换。
117
-
118
- ## Common Mistakes
119
-
120
- | 错误 | 正确做法 |
121
- |------|----------|
122
- | 把妙搭 userId 当飞书 open_id 传给飞书 API | 两套体系不互通;nestjs-react-fullstack 项目内用 `AuthNPaasService`,其他场景调 spark `id_convert` |
123
- | 混淆 `req.userContext.userId` 和 `useCurrentUserProfile().user_id` | 同一个 ID,dataloom 同源,前后端获取方式不同而已 |
124
- | 把 `employee_id` 当作员工工号 | `employee_id` 等价于 `user_id`,是企业内身份标识;工号是另一个独立字段 `employee_no` |
125
- | 误以为 `AuthNPaasService` 能反查(飞书 user_id → 妙搭 userId) | 只支持 妙搭 userId → 飞书 user_id 单向;反向需要"通讯录 API user_id → open_id"+"spark id_convert type 20 open_id → 妙搭 userId"两步 |
126
- | 想用飞书 user_id (employee_id) 直接调 spark `id_convert` 反查妙搭 | spark `id_convert` 只支持 open_id (type 20) / union_id (type 21) 反查,不支持 user_id;走两步方案 |
127
- | 逐个调用 ID 转换接口 | 一次传入多个 ID(最多 100 个),减少请求次数 |
128
- | 用 `client.spark.*` 语义化调用 | SDK 无内置 spark 模块,需用 `client.request()` 通用方式 |
@@ -1,207 +0,0 @@
1
- # 消息 (Messaging)
2
-
3
- > 开放平台文档(Markdown 版):https://open.larkoffice.com/document/server-docs/im-v1/introduction.md
4
-
5
- 使用 `@larksuiteoapi/node-sdk` 在 NestJS 中发送和回复飞书消息。
6
-
7
- ## 所需权限
8
-
9
- | 权限标识 | 说明 |
10
- |----------|------|
11
- | `im:message:send_as_bot` | 以应用身份发送消息 |
12
-
13
- ## 发送消息
14
-
15
- ```typescript
16
- // 发送文本消息
17
- const res = await client.im.message.create({
18
- params: { receive_id_type: 'chat_id' },
19
- data: {
20
- receive_id: 'oc_xxx',
21
- content: JSON.stringify({ text: '你好,这是一条测试消息' }),
22
- msg_type: 'text',
23
- },
24
- });
25
- if (res.code !== 0) throw new Error(`[${res.code}] ${res.msg}`);
26
- ```
27
-
28
- ### receive_id_type 与 ID 前缀对应关系
29
-
30
- | receive_id_type | ID 前缀 | 说明 |
31
- |-----------------|---------|------|
32
- | `chat_id` | `oc_` | 群聊 ID |
33
- | `open_id` | `ou_` | 用户 open_id |
34
- | `union_id` | `on_` | 用户 union_id |
35
- | `user_id` | 无固定前缀 | 用户 user_id |
36
- | `email` | - | 用户邮箱 |
37
-
38
- ## 发送富文本消息
39
-
40
- ```typescript
41
- await client.im.message.create({
42
- params: { receive_id_type: 'chat_id' },
43
- data: {
44
- receive_id: 'oc_xxx',
45
- content: JSON.stringify({
46
- zh_cn: {
47
- title: '项目更新',
48
- content: [
49
- [
50
- { tag: 'text', text: '版本 2.0 已发布,主要更新:' },
51
- { tag: 'a', text: '查看详情', href: 'https://example.com' },
52
- ],
53
- ],
54
- },
55
- }),
56
- msg_type: 'post',
57
- },
58
- });
59
- ```
60
-
61
- ## 发送卡片消息
62
-
63
- ```typescript
64
- // 方式 1:JSON 卡片
65
- await client.im.message.create({
66
- params: { receive_id_type: 'chat_id' },
67
- data: {
68
- receive_id: 'oc_xxx',
69
- content: JSON.stringify({
70
- header: {
71
- template: 'blue',
72
- title: { content: '卡片标题', tag: 'plain_text' },
73
- },
74
- elements: [
75
- { tag: 'markdown', content: '**进度更新**\n- 前端:80%\n- 后端:60%' },
76
- {
77
- tag: 'action',
78
- actions: [
79
- { tag: 'button', text: { tag: 'plain_text', content: '确认' }, type: 'primary' },
80
- ],
81
- },
82
- ],
83
- }),
84
- msg_type: 'interactive',
85
- },
86
- });
87
-
88
- // 方式 2:SDK 内置默认卡片(快速使用)
89
- await client.im.message.create({
90
- params: { receive_id_type: 'chat_id' },
91
- data: {
92
- receive_id: 'oc_xxx',
93
- content: lark.messageCard.defaultCard({ title: '标题', content: '内容' }),
94
- msg_type: 'interactive',
95
- },
96
- });
97
-
98
- // 方式 3:卡片模板(推荐,在卡片搭建工具中配置后使用 template_id)
99
- await client.im.message.createByCard({
100
- params: { receive_id_type: 'chat_id' },
101
- data: {
102
- receive_id: 'oc_xxx',
103
- template_id: 'your_template_id',
104
- template_variable: { title: '标题', content: '正文内容' },
105
- },
106
- });
107
- ```
108
-
109
- ## 回复消息
110
-
111
- ```typescript
112
- await client.im.message.reply({
113
- path: { message_id: 'om_xxx' },
114
- data: {
115
- content: JSON.stringify({ text: '收到,我马上处理' }),
116
- msg_type: 'text',
117
- },
118
- });
119
- ```
120
-
121
- ## 编辑消息(24h 内)
122
-
123
- ```typescript
124
- await client.im.message.update({
125
- path: { message_id: 'om_xxx' },
126
- data: {
127
- content: JSON.stringify({ text: '已更新的消息内容' }),
128
- msg_type: 'text',
129
- },
130
- });
131
- ```
132
-
133
- ## 更新卡片消息
134
-
135
- ```typescript
136
- await client.im.message.patch({
137
- path: { message_id: 'om_xxx' },
138
- data: {
139
- content: JSON.stringify({
140
- elements: [{ tag: 'markdown', content: '已更新的卡片内容' }],
141
- }),
142
- },
143
- });
144
- ```
145
-
146
- ## 消息内容格式
147
-
148
- ### text(文本)
149
-
150
- ```json
151
- {"text": "你好,这是一条消息"}
152
- ```
153
-
154
- 支持 @ 用户:`{"text": "<at user_id=\"ou_xxx\">张三</at> 请查看"}`
155
-
156
- ### post(富文本)
157
-
158
- ```json
159
- {
160
- "zh_cn": {
161
- "title": "标题",
162
- "content": [
163
- [
164
- {"tag": "text", "text": "普通文本"},
165
- {"tag": "a", "text": "链接文字", "href": "https://example.com"},
166
- {"tag": "at", "user_id": "ou_xxx"}
167
- ]
168
- ]
169
- }
170
- }
171
- ```
172
-
173
- ### interactive(卡片)
174
-
175
- ```json
176
- {
177
- "header": {
178
- "template": "blue",
179
- "title": {"content": "卡片标题", "tag": "plain_text"}
180
- },
181
- "elements": [
182
- {"tag": "markdown", "content": "**标题**\n内容文本"},
183
- {
184
- "tag": "action",
185
- "actions": [
186
- {"tag": "button", "text": {"tag": "plain_text", "content": "按钮"}, "type": "primary"}
187
- ]
188
- }
189
- ]
190
- }
191
- ```
192
-
193
- ## 使用限制
194
-
195
- - 向同一用户发消息限频:**5 QPS**
196
- - 向同一群组发消息限频:群内机器人共享 **5 QPS**
197
- - 文本消息最大 **150 KB**
198
- - 卡片/富文本消息最大 **30 KB**
199
-
200
- ## Common Mistakes
201
-
202
- | 错误 | 正确做法 |
203
- |------|----------|
204
- | `content` 传对象 | 必须 `JSON.stringify({text: 'hello'})` |
205
- | 群聊 ID 用 `open_id` 类型 | `oc_` 开头的是 `chat_id` |
206
- | 富文本 content 不是二维数组 | `content: [[{tag:'text', text:'...'}]]` 外层是行数组 |
207
- | 忘记开启机器人能力 | 应用能力 → 添加机器人 |
@@ -1,164 +0,0 @@
1
- # OAuth 用户授权
2
-
3
- > 开放平台文档(Markdown 版):https://open.larkoffice.com/document/ukTMukTMukTM/uMTNz4yM1MjLzUzM
4
-
5
- 默认的 tenant_access_token 以应用身份调用 API。
6
- 部分场景(个人日历、搜索联系人)需要 user_access_token 代表用户操作。
7
-
8
- ## 授权流程
9
-
10
- ### 1. 构造 redirect_uri 并重定向用户到授权页面
11
-
12
- ```typescript
13
- // 域名和路径根从运行时获取
14
- const redirectUri = `https://${req.hostname}${process.env.CLIENT_BASE_PATH}/feishu/oauth/callback`;
15
- const authorizeUrl = `https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=${FEISHU_APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`;
16
- // 返回 302 重定向或将 URL 返回给前端
17
- ```
18
-
19
- ### 2. 回调中用 code 换取 token
20
-
21
- ```typescript
22
- const userId = req.userContext.userId; // 当前登录用户
23
- await client.userAccessToken.initWithCode({ [userId]: code });
24
- ```
25
-
26
- ### 3. 获取 token(自动 refresh)
27
-
28
- ```typescript
29
- const token = await client.userAccessToken.get(userId);
30
- ```
31
-
32
- ### 4. 携带 user_access_token 调用 API
33
-
34
- ```typescript
35
- await client.calendar.calendarEvent.create(
36
- { data: { summary: '我的日程', ... } },
37
- lark.withUserAccessToken(token),
38
- );
39
- ```
40
-
41
- ## Token 持久化与用户关联
42
-
43
- SDK 默认将 token 存在内存,重启后丢失。生产环境需持久化到数据库。
44
-
45
- ### 建表
46
-
47
- ```sql
48
- CREATE TABLE feishu_user_token (
49
- user_id VARCHAR(64) PRIMARY KEY, -- req.userContext.userId
50
- open_id VARCHAR(64) NOT NULL, -- 飞书用户标识
51
- access_token TEXT NOT NULL,
52
- refresh_token TEXT NOT NULL,
53
- token_expire_at TIMESTAMPTZ NOT NULL,
54
- refresh_expire_at TIMESTAMPTZ NOT NULL,
55
- updated_at TIMESTAMPTZ DEFAULT NOW()
56
- );
57
- ```
58
-
59
- ### FeishuOAuthService 示例
60
-
61
- 回调保存和 token 刷新的完整 NestJS Service 实现:
62
-
63
- - `access_token` 有效期 2 小时,`refresh_token` 有效期 30 天
64
- - 每次 refresh 后,服务端会同时返回**新的 `refresh_token`**(token rotation),必须用新值覆盖旧值,否则下次刷新会失败
65
-
66
- ```typescript
67
- import { Injectable, Inject } from '@nestjs/common';
68
- import { DRIZZLE_DATABASE, type PostgresJsDatabase } from '@lark-apaas/fullstack-nestjs-core';
69
- import { eq } from 'drizzle-orm';
70
- import { feishuUserToken } from '@server/database/schema';
71
-
72
- @Injectable()
73
- export class FeishuOAuthService {
74
- private readonly EXPIRY_BUFFER_MS = 3 * 60 * 1000; // 预留 3 分钟 buffer
75
-
76
- constructor(
77
- private readonly feishuService: FeishuService,
78
- @Inject(DRIZZLE_DATABASE) private readonly db: PostgresJsDatabase,
79
- ) {}
80
-
81
- /** 回调中用 code 换取 token 并持久化(Step 2 之后调用) */
82
- async handleCallback(userId: string, code: string) {
83
- const client = this.feishuService.getClient();
84
- const tokenRes = await client.authen.oidcAccessToken.create({
85
- data: { grant_type: 'authorization_code', code },
86
- });
87
- if (tokenRes.code !== 0 || !tokenRes.data) {
88
- throw new Error(`Feishu OAuth token error [${tokenRes.code}]: ${tokenRes.msg}`);
89
- }
90
- const { access_token, refresh_token, open_id, expires_in, refresh_expires_in } = tokenRes.data;
91
- const now = Date.now();
92
- await this.db.insert(feishuUserToken).values({
93
- userId,
94
- openId: open_id,
95
- accessToken: access_token,
96
- refreshToken: refresh_token,
97
- tokenExpireAt: new Date(now + expires_in * 1000),
98
- refreshExpireAt: new Date(now + refresh_expires_in * 1000),
99
- }).onConflictDoUpdate({
100
- target: feishuUserToken.userId,
101
- set: {
102
- openId: open_id,
103
- accessToken: access_token,
104
- refreshToken: refresh_token,
105
- tokenExpireAt: new Date(now + expires_in * 1000),
106
- refreshExpireAt: new Date(now + refresh_expires_in * 1000),
107
- },
108
- });
109
- }
110
-
111
- /** 获取有效的 user_access_token,自动刷新 */
112
- async getValidToken(userId: string): Promise<string> {
113
- const [stored] = await this.db
114
- .select()
115
- .from(feishuUserToken)
116
- .where(eq(feishuUserToken.userId, userId));
117
- if (!stored) throw new Error('用户未授权飞书,请先完成 OAuth 授权');
118
-
119
- const now = Date.now();
120
-
121
- // 1. access_token 未过期 → 直接返回
122
- if (stored.tokenExpireAt.getTime() - now > this.EXPIRY_BUFFER_MS) {
123
- return stored.accessToken;
124
- }
125
-
126
- // 2. access_token 过期,但 refresh_token 未过期 → 刷新
127
- if (stored.refreshExpireAt.getTime() - now > this.EXPIRY_BUFFER_MS) {
128
- const client = this.feishuService.getClient();
129
- const res = await client.authen.oidcRefreshAccessToken.create({
130
- data: { grant_type: 'refresh_token', refresh_token: stored.refreshToken },
131
- });
132
- if (res.code !== 0 || !res.data) {
133
- throw new Error(`Feishu OAuth refresh error [${res.code}]: ${res.msg}`);
134
- }
135
- const { access_token, refresh_token, expires_in, refresh_expires_in } = res.data;
136
-
137
- // 必须同时更新 access_token 和 refresh_token(token rotation)
138
- await this.db.update(feishuUserToken)
139
- .set({
140
- accessToken: access_token,
141
- refreshToken: refresh_token,
142
- tokenExpireAt: new Date(now + expires_in * 1000),
143
- refreshExpireAt: new Date(now + refresh_expires_in * 1000),
144
- })
145
- .where(eq(feishuUserToken.userId, userId));
146
- return access_token;
147
- }
148
-
149
- // 3. refresh_token 也过期 → 需要用户重新授权(回到第 1 步)
150
- throw new Error('飞书授权已过期,请重新授权');
151
- }
152
- }
153
- ```
154
-
155
- #### 在业务代码中使用
156
-
157
- ```typescript
158
- // 在 Controller 或其他 Service 中注入 FeishuOAuthService
159
- const token = await this.feishuOAuthService.getValidToken(req.userContext.userId);
160
- await this.feishuService.getClient().calendar.calendarEvent.create(
161
- { data: { summary: '我的日程', ... } },
162
- lark.withUserAccessToken(token),
163
- );
164
- ```