@lark-apaas/coding-steering 0.1.76 → 0.1.78
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/package.json +1 -1
- package/steering/nestjs-react-fullstack/skills/anycross-forward/SKILL.md +132 -46
- package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +2 -1
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +3 -1
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +25 -1
- package/steering/nestjs-react-fullstack/skills/tag-agent-task/SKILL.md +166 -0
- package/steering/nestjs-react-fullstack/skills/tag-agent-task/references/example.md +268 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/SKILL.md +8 -1
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/debugging.md +2 -4
- package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +28 -5
- package/steering/vite-react/skills/plugin-guide/SKILL.md +2 -0
- package/steering/vite-react/skills/plugin-guide/references/table.md +15 -0
package/package.json
CHANGED
|
@@ -16,14 +16,14 @@ unavailable-agents:
|
|
|
16
16
|
|
|
17
17
|
# anycross-forward 内网请求转发指南
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
通过平台内置的 http-forwarder 把请求转发到客户内网地址。后端 SDK 通过 `PlatformModule.forRoot()` **自动装配**,业务零接入。**默认在 node 侧用 `HttpForwarderService.fetch()` 调用**;前端 `axiosForBackend` 直调 `/api/__platform__/http-forward` 只用于"无凭证、只读、URL 可暴露"的例外场景。
|
|
20
20
|
|
|
21
21
|
> SDK 不感知代理 / 鉴权 / 隧道。实际通路由部署环境(沙箱 mihomo + iptables)处理,生成代码里**不要**拼 `proxy_group_id` / `jwtToken` / `anycross-host`。
|
|
22
22
|
|
|
23
23
|
## 一、决策树
|
|
24
24
|
|
|
25
25
|
```text
|
|
26
|
-
|
|
26
|
+
需要请求一个 HTTP 接口?
|
|
27
27
|
│
|
|
28
28
|
├─ 目标是平台内置 capability / Dataloom?
|
|
29
29
|
│ └─ 是 ──→ 用对应 SDK,不走 forwarder
|
|
@@ -31,29 +31,91 @@ unavailable-agents:
|
|
|
31
31
|
├─ 目标是公网公共 API(github.com 等)?
|
|
32
32
|
│ └─ 是 ──→ 直接 fetch / axios(不走 forwarder)
|
|
33
33
|
│
|
|
34
|
-
├─ 目标是客户内网 HTTP/HTTPS API
|
|
35
|
-
│
|
|
34
|
+
├─ 目标是客户内网 HTTP/HTTPS API?(默认 node 侧)
|
|
35
|
+
│ ├─ 需要凭证(API key / 内网 token) ──→ ✅ node 侧 fetch(),凭证放 env
|
|
36
|
+
│ ├─ targetUrl 不应由用户控制(固定内网系统) ──→ ✅ node 侧 fetch(),URL 写死在 service
|
|
37
|
+
│ ├─ 定时任务 / trigger / 服务端聚合 ──→ ✅ node 侧 fetch()(必然)
|
|
38
|
+
│ └─ 公开给员工的只读接口、无凭证、URL 可暴露 ──→ ✅ 前端 axiosForBackend 直调(例外)
|
|
36
39
|
│
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
├─ 二进制?
|
|
41
|
+
│ ├─ 上传(body 为 Blob / FormData / Buffer / 流) ──→ ❌ 不支持,body 仅 string / JSON
|
|
42
|
+
│ └─ 下载 ──→ ✅ 前端 `responseType: 'blob' | 'arraybuffer'`;node 侧 `res.arrayBuffer()`
|
|
43
|
+
│
|
|
44
|
+
└─ 目标是 ws:// / file://?
|
|
45
|
+
└─ 是 ──→ ❌ 不支持,仅 http:// / https://
|
|
39
46
|
```
|
|
40
47
|
|
|
48
|
+
为什么默认 node 侧:前端直调时 `targetUrl` 由浏览器传入,**任何登录用户都能借 forwarder 打任意内网 URL**,内网凭证也只能写进前端代码。node 侧 URL 写死在 service、凭证从 env 读,前端只暴露业务接口。
|
|
49
|
+
|
|
41
50
|
## 二、Quick reference
|
|
42
51
|
|
|
43
|
-
| 项 | 值
|
|
44
|
-
| ------------ |
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
|
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
| 项 | 值 |
|
|
53
|
+
| ------------ | ---------------------------------------------------------------------------------------------------- |
|
|
54
|
+
| node 侧 SDK | `HttpForwarderService` from `@lark-apaas/nestjs-http-forwarder`,`fetch(targetUrl, init?)` 返回 Web `Response` |
|
|
55
|
+
| 端点路径 | `ALL /api/__platform__/http-forward`(前端直调例外场景) |
|
|
56
|
+
| Query 参数 | `targetUrl` (必填,仅 `http://` / `https://`) |
|
|
57
|
+
| 前端 SDK | `axiosForBackend` from `@lark-apaas/client-toolkit/utils/getAxiosForBackend` |
|
|
58
|
+
| HTTP method | 透传:GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS |
|
|
59
|
+
| Body 形态 | string / JSON-able object(axios 与 `fetch()` 均自动序列化) / null |
|
|
60
|
+
| Headers | 自动透传(剔除 hop-by-hop / accept-encoding / host / content-length) |
|
|
61
|
+
| 响应 | 上游 `status` / `headers` / `data` 原样投射;二进制下载可用 |
|
|
62
|
+
| 审计上下文 | SDK 自动注入 `x-miaoda-ctx`(base64 JSON:app_id / tenant_id / user_id / is_system_account),不可覆盖 |
|
|
63
|
+
| 默认超时 | 30000 ms(可在 `PlatformModule.forRoot({ httpForwarder })` 改) |
|
|
64
|
+
| 默认响应上限 | 10485760 bytes (10 MB) |
|
|
65
|
+
| 鉴权 | SDK **不内置**;前端直调路由由业务方加 `@NeedLogin()` 等 guard |
|
|
66
|
+
|
|
67
|
+
## 三、node 侧调用(默认)
|
|
68
|
+
|
|
69
|
+
`HttpForwarderService` 由 `PlatformModule` 全局导出,构造器直接注入即可,**不要**在用户 `AppModule` 里 import `HttpForwarderModule`。
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// server/src/modules/corp-api/corp-api.service.ts
|
|
73
|
+
import { Injectable } from "@nestjs/common";
|
|
74
|
+
import { HttpForwarderService } from "@lark-apaas/nestjs-http-forwarder";
|
|
75
|
+
|
|
76
|
+
const BASE = "http://api.corp.com/v1"; // URL 写死在 service,不接受前端传入
|
|
77
|
+
|
|
78
|
+
@Injectable()
|
|
79
|
+
export class CorpApiService {
|
|
80
|
+
constructor(private readonly forwarder: HttpForwarderService) {}
|
|
81
|
+
|
|
82
|
+
// GET — 带内网凭证,凭证只从 env 读、只在 node 侧出现
|
|
83
|
+
async listOrders() {
|
|
84
|
+
const res = await this.forwarder.fetch(`${BASE}/orders`, {
|
|
85
|
+
headers: { "x-api-key": process.env.CORP_API_KEY! },
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
// 上游 4xx/5xx 不抛错(ok=false);失败也要读完 body 再 throw,
|
|
89
|
+
// 否则连接会占用到 requestTimeoutMs(默认 30s)才释放
|
|
90
|
+
throw new Error(`corp api ${res.status}: ${await res.text()}`);
|
|
91
|
+
}
|
|
92
|
+
return res.json();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// POST — object body 自动 JSON.stringify 并补 content-type: application/json
|
|
96
|
+
async createOrder(payload: { qty: number }) {
|
|
97
|
+
const res = await this.forwarder.fetch(`${BASE}/orders`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
body: payload,
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) throw new Error(`corp api ${res.status}: ${await res.text()}`);
|
|
102
|
+
return res.json();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- `fetch()` 返回 Web `Response`(`.json()` / `.text()` / `.arrayBuffer()`);上游 4xx/5xx 返回 `ok=false` **不抛**;forwarder 自身错(非法 URL / 上游不可达 / 超时 / 非法 header / 不支持的 body)抛 `HttpException`,`getResponse()` 为 `{ code, message }`。
|
|
108
|
+
- body 仅 `string` / JSON-able object / `null`;Buffer / FormData / URLSearchParams / Blob / 流会被拒绝为 400 `INVALID_REQUEST`。
|
|
109
|
+
- 不需要响应内容时 `await res.body?.cancel()`,否则连接占用到超时。
|
|
110
|
+
- **低层路径**:`forward(dto)` 返回 `{ status, headers, body: Readable }`,配 `toWebResponse(out)` 转成 Web `Response`。仅在自定义 controller 需要把上游流 pipe 给下游等流式场景使用,普通业务一律 `fetch()`。
|
|
111
|
+
|
|
112
|
+
**禁止在 node 侧用裸 `fetch` / `axios` 打内网**,三个原因:
|
|
113
|
+
|
|
114
|
+
1. Node 内置 fetch 不读 `HTTP_PROXY`,请求不会走出网代理;
|
|
115
|
+
2. 代理鉴权在首次 `forward()` / `fetch()` 才 lazy 初始化,绕过 SDK 就没有鉴权;
|
|
116
|
+
3. 不会带 `x-miaoda-ctx` 审计头,内网侧无法审计调用方。
|
|
117
|
+
|
|
118
|
+
## 四、前端直调(例外:无凭证、只读、URL 可暴露)
|
|
57
119
|
|
|
58
120
|
```ts
|
|
59
121
|
// client/src/api/corp-api.ts
|
|
@@ -62,7 +124,7 @@ import { axiosForBackend } from "@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
62
124
|
const FORWARD = "/api/__platform__/http-forward";
|
|
63
125
|
const BASE = "http://api.corp.com/v1";
|
|
64
126
|
|
|
65
|
-
// GET —
|
|
127
|
+
// GET — 拉公开只读列表
|
|
66
128
|
export async function fetchUsers() {
|
|
67
129
|
const res = await axiosForBackend.get(FORWARD, {
|
|
68
130
|
params: { targetUrl: `${BASE}/users` },
|
|
@@ -70,26 +132,28 @@ export async function fetchUsers() {
|
|
|
70
132
|
return res.data; // 上游真实响应 data
|
|
71
133
|
}
|
|
72
134
|
|
|
73
|
-
// POST —
|
|
135
|
+
// POST — body 直接传对象,自定义 headers 自动透传上游
|
|
74
136
|
export async function createOrder(payload: { qty: number }) {
|
|
75
137
|
const res = await axiosForBackend.post(FORWARD, payload, {
|
|
76
138
|
params: { targetUrl: `${BASE}/orders` },
|
|
77
|
-
headers: { "X-Tenant": "demo" },
|
|
139
|
+
headers: { "X-Tenant": "demo" },
|
|
78
140
|
});
|
|
79
141
|
return res.data;
|
|
80
142
|
}
|
|
81
143
|
|
|
82
|
-
//
|
|
83
|
-
export async function
|
|
84
|
-
await axiosForBackend.
|
|
85
|
-
params: { targetUrl: `${BASE}/
|
|
144
|
+
// 二进制下载 — axios 整包接收
|
|
145
|
+
export async function downloadReport(id: string) {
|
|
146
|
+
const res = await axiosForBackend.get(FORWARD, {
|
|
147
|
+
params: { targetUrl: `${BASE}/reports/${id}.pdf` },
|
|
148
|
+
responseType: "blob",
|
|
86
149
|
});
|
|
150
|
+
return res.data as Blob;
|
|
87
151
|
}
|
|
88
152
|
```
|
|
89
153
|
|
|
90
|
-
##
|
|
154
|
+
## 五、错误处理模板(前端)
|
|
91
155
|
|
|
92
|
-
> ⚠️ 模板的 `GlobalExceptionFilter`(`server/common/filters/exception.filter.ts`)会把 forwarder 抛的 `HttpException` 重新打包为 `{ error: { code: 'BAD_REQUEST' | 'BAD_GATEWAY' | ..., message, details } }`:顶层 `error.code` 是 HTTP 状态映射,**forwarder 自己的业务码(`INVALID_REQUEST` / `UPSTREAM_TIMEOUT` 等)被 `JSON.stringify` 进 `error.details`**,需要 `JSON.parse` 才能拿到。
|
|
156
|
+
> ⚠️ 模板的 `GlobalExceptionFilter`(`server/common/filters/exception.filter.ts`)会把 forwarder 抛的 `HttpException` 重新打包为 `{ error: { code: 'BAD_REQUEST' | 'BAD_GATEWAY' | ..., message, details } }`:顶层 `error.code` 是 HTTP 状态映射,**forwarder 自己的业务码(`INVALID_REQUEST` / `UPSTREAM_TIMEOUT` 等)被 `JSON.stringify` 进 `error.details`**,需要 `JSON.parse` 才能拿到。node 侧 `fetch()` 直接 catch `HttpException`,`err.getResponse()` 即 `{ code, message }`,无需解包。
|
|
93
157
|
|
|
94
158
|
```ts
|
|
95
159
|
import { axiosForBackend } from "@lark-apaas/client-toolkit/utils/getAxiosForBackend";
|
|
@@ -123,7 +187,7 @@ export async function safeForward(targetUrl: string) {
|
|
|
123
187
|
switch (inner?.code) {
|
|
124
188
|
case "INVALID_REQUEST":
|
|
125
189
|
case "INVALID_TARGET_PROTOCOL":
|
|
126
|
-
// targetUrl 缺失 / 格式非法 / 协议非 http(s)
|
|
190
|
+
// targetUrl 缺失 / 格式非法 / 协议非 http(s) / body 或 header 不合法
|
|
127
191
|
return { ok: false, reason: "targetUrl 配置错误" };
|
|
128
192
|
case "UPSTREAM_TIMEOUT":
|
|
129
193
|
return { ok: false, reason: "上游 30s 超时" };
|
|
@@ -133,6 +197,13 @@ export async function safeForward(targetUrl: string) {
|
|
|
133
197
|
return { ok: false, reason: inner.message ?? "上游不可达" };
|
|
134
198
|
case "RESPONSE_TOO_LARGE":
|
|
135
199
|
return { ok: false, reason: "上游响应 > 10MB" };
|
|
200
|
+
case "MISSING_APP_ID":
|
|
201
|
+
case "APP_ID_MISMATCH":
|
|
202
|
+
case "PROXY_TOKEN_FETCH_FAILED":
|
|
203
|
+
case "PROXY_CMD_EXEC_FAILED":
|
|
204
|
+
// 500:内网通道平台侧故障(代理鉴权 / 沙箱 appId 环境问题),
|
|
205
|
+
// 不是业务代码的错——提示用户联系平台,不要重试、不要改代码绕过
|
|
206
|
+
return { ok: false, reason: "内网通道暂不可用,请联系平台" };
|
|
136
207
|
default:
|
|
137
208
|
throw err;
|
|
138
209
|
}
|
|
@@ -140,41 +211,56 @@ export async function safeForward(targetUrl: string) {
|
|
|
140
211
|
}
|
|
141
212
|
```
|
|
142
213
|
|
|
143
|
-
##
|
|
214
|
+
## 六、内网侧看到的调用方信息
|
|
215
|
+
|
|
216
|
+
所有经 forwarder 出站的请求(node 侧 `fetch()` / `forward()`、前端直调)都自动带 `x-miaoda-ctx` 头:标准 base64 编码的 UTF-8 JSON,四个 key 永远存在,缺省 `""` / `false`:
|
|
217
|
+
|
|
218
|
+
```json
|
|
219
|
+
{ "app_id": "app_xxx", "tenant_id": "1234", "user_id": "ou_abc", "is_system_account": false }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`user_id` 匿名访问为 `""`;trigger / 定时任务场景 `is_system_account` 为 `true`。内容是 **app 自述、SDK 不签名**,下游只用于审计("是哪个 app / 租户 / 用户发起的"),**权威 app 身份以代理层鉴权结果为准**,不要拿此 header 做授权决策。业务代码对它完全透明:不需要读、不能改。
|
|
223
|
+
|
|
224
|
+
## 七、生成代码必须遵守的 7 条
|
|
144
225
|
|
|
145
|
-
1.
|
|
146
|
-
2.
|
|
147
|
-
3. **服务端零接入**——`PlatformModule.forRoot()` 已自动装配 `HttpForwarderModule
|
|
148
|
-
4. **前端只用 `axiosForBackend`**,它已带 401 → `x-login-url`
|
|
149
|
-
5. **响应原样投射**:`res.status` / `res.headers` / `res.data`
|
|
226
|
+
1. **内网调用默认在 node 侧**:注入 `HttpForwarderService` 用 `fetch()`,URL 写死在 service、凭证只从 `process.env` 读;前端直调仅限无凭证只读接口。
|
|
227
|
+
2. **前端直调路径恒为 `/api/__platform__/http-forward`**,`targetUrl` 永远放 `params`,不要拼到 URL 字符串里、不要放 body、不要放 header。
|
|
228
|
+
3. **服务端零接入**——`PlatformModule.forRoot()` 已自动装配 `HttpForwarderModule` 并全局导出 service,**不要**在用户 `AppModule` 里再写 `HttpForwarderModule.forRoot()`。
|
|
229
|
+
4. **前端只用 `axiosForBackend`**,它已带 401 → `x-login-url` 自动跳转拦截器,**不要**再实现登录跳转;**node 侧只用 `HttpForwarderService`**,不要裸 `fetch` / `axios`。
|
|
230
|
+
5. **响应原样投射**:`res.status` / `res.headers` / `res.data` 都来自上游真实响应,上游 4xx/5xx 不是转发失败。forwarder 自身错在前端被模板 `GlobalExceptionFilter` 包成 `error.response.data.error`,业务码需从 `error.details`(JSON 字符串)里 parse 出来,详见第五节。
|
|
150
231
|
6. **headers 直接走 `headers: {...}`**,无需手剔 `host` / `content-length` / hop-by-hop,service 端会处理。
|
|
232
|
+
7. **不要手动设置 `x-miaoda-ctx`**,SDK 会无条件覆盖;也不要在业务代码里读它做授权。
|
|
151
233
|
|
|
152
|
-
##
|
|
234
|
+
## 八、Common Mistakes
|
|
153
235
|
|
|
154
236
|
| ❌ 错误 | ✅ 正确 |
|
|
155
237
|
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
238
|
+
| node 侧用裸 `fetch(url)` / `axios.get(url)` 打内网 | 注入 `HttpForwarderService` 用 `fetch()`(裸 fetch 不走代理、无鉴权、无审计头) |
|
|
239
|
+
| 前端直调带内网 API key / token,或让用户输入 `targetUrl` | 改 node 侧 `fetch()`:URL 写死在 service,凭证从 `process.env` 读 |
|
|
156
240
|
| `axiosForBackend.post('/api/__platform__/http-forward?targetUrl=...')` 手拼 query | `params: { targetUrl }`,让 axios 做 URL 编码 |
|
|
157
241
|
| `axiosForBackend.post(targetUrl, body)` 把 targetUrl 当路径 | 路径永远 `/api/__platform__/http-forward`,targetUrl 走 `params` |
|
|
158
242
|
| 用裸 `axios.get('/api/__platform__/http-forward', ...)`(直接 import 'axios') | 用 `axiosForBackend`(带 baseURL / 401 拦截器) |
|
|
159
|
-
| body 传 `FormData` / `Blob` /
|
|
243
|
+
| body 传 `FormData` / `Blob` / `Buffer` / 流做上传 | 当前仅支持 string / JSON-able / null,否则 400 `INVALID_REQUEST`;二进制上传先 base64 成字符串,下载用 `responseType: 'blob'` |
|
|
160
244
|
| 上游 4xx/5xx 被当作转发失败 | 上游响应**原样投射**到 `res`;只有 forwarder 自身错才进 catch,业务码在 `error.response.data.error.details`(需 `JSON.parse`) |
|
|
245
|
+
| node 侧 `fetch()` 失败分支不读 body 直接 throw | 先 `await res.text()`(或 `await res.body?.cancel()`)再 throw,否则连接占用到超时 |
|
|
161
246
|
| 用户 `AppModule` 手写 `HttpForwarderModule.forRoot()` | 已被 `PlatformModule` 自动装配,重复注册冲突 |
|
|
162
247
|
| 拼 `?targetUrl=...&proxy_group_id=xxx&jwt=yyy` 等自定义参数 | 部署环境接管路由,SDK 不感知;唯一参数是 `targetUrl` |
|
|
163
248
|
| `targetUrl: 'ws://...'` / `'file://...'` | 仅 `http://` / `https://`,否则 `INVALID_TARGET_PROTOCOL` |
|
|
164
249
|
| 在生成代码里手动加 `Authorization` 模拟 anycross | 部署环境处理鉴权,业务代码只关心**业务**header |
|
|
165
|
-
|
|
|
250
|
+
| 手拼 `x-miaoda-ctx` header | 无效:SDK 无条件覆盖;审计上下文由 SDK 从请求上下文自动生成 |
|
|
251
|
+
| 收到 500 `PROXY_*` / `*_APP_ID*` 错误后重试或改代码绕过 | 是内网通道平台侧故障,提示用户联系平台 |
|
|
166
252
|
|
|
167
|
-
##
|
|
253
|
+
## 九、当前限制
|
|
168
254
|
|
|
169
|
-
- **Body**:仅 `string` / JSON-able object / `null`;不支持 binary / FormData /
|
|
170
|
-
-
|
|
255
|
+
- **Body**:仅 `string` / JSON-able object / `null`;不支持 binary / FormData / 流上传
|
|
256
|
+
- **响应**:二进制下载 OK(流式 pipe 到下游);前端 axios 整包接收,不承诺 SSE 式流消费;node 侧流式只能走 `forward()` 低层路径
|
|
171
257
|
- **不做请求重试**(透传语义,重试由业务方决定)
|
|
172
258
|
- **超时 / 响应上限可配**:业务方可在 `PlatformModule.forRoot({ httpForwarder: { requestTimeoutMs, maxResponseBytes } })` 处覆盖默认值
|
|
173
|
-
- **错误码 contract
|
|
259
|
+
- **错误码 contract**:400 `INVALID_REQUEST` / `INVALID_TARGET_PROTOCOL`;502 `UPSTREAM_UNREACHABLE` / `RESPONSE_TOO_LARGE`;504 `UPSTREAM_TIMEOUT`;500 `MISSING_APP_ID` / `APP_ID_MISMATCH` / `PROXY_TOKEN_FETCH_FAILED` / `PROXY_CMD_EXEC_FAILED`(平台侧故障)
|
|
174
260
|
|
|
175
|
-
##
|
|
261
|
+
## 十、相关
|
|
176
262
|
|
|
177
|
-
- 后端 SDK
|
|
263
|
+
- 后端 SDK:`@lark-apaas/nestjs-http-forwarder`(导出 `HttpForwarderService` / `toWebResponse` / `MIAODA_CTX_HEADER` / `MiaodaCtxPayload` / `ForwardFetchInit`)
|
|
178
264
|
- 前端 SDK:`axiosForBackend` from `@lark-apaas/client-toolkit/utils/getAxiosForBackend`
|
|
179
265
|
- 自动装配点:`@lark-apaas/nestjs-core` 的 `PlatformModule`
|
|
180
|
-
- 鉴权约束:参见 `user-identity`——forwarder
|
|
266
|
+
- 鉴权约束:参见 `user-identity`——forwarder 不内置鉴权,前端直调路由的 `@NeedLogin()` 仍生效
|
|
@@ -84,7 +84,7 @@ available-agents:
|
|
|
84
84
|
| 需要数据存储、用户隔离、后端逻辑 | 全栈 |
|
|
85
85
|
| 涉及文件上传与管理 | 全栈 + 文件服务 |
|
|
86
86
|
| 服务端图像、音视频处理(易引入 native addon) | 先确认能否改用 client 处理、平台服务或纯 JS/WASM |
|
|
87
|
-
| 以飞书多维表格为数据源 |
|
|
87
|
+
| 以飞书多维表格为数据源 | 插件优先;数据量大或复杂查询场景评估同步至应用数据库(`+db-sync-create`) |
|
|
88
88
|
| 需要飞书通知、群组、AI 能力 | 插件集成,标记插件实例需求 |
|
|
89
89
|
| 需要定时或事件驱动的后台处理 | 全栈 + 自动化任务 |
|
|
90
90
|
|
|
@@ -95,6 +95,7 @@ available-agents:
|
|
|
95
95
|
| 应用自身创建和维护的数据 | 本地数据库 |
|
|
96
96
|
| 飞书多维表格为既有事实源 | 通过多维表格插件读写 |
|
|
97
97
|
| 多维表格上的统计看板 | 优先使用插件聚合能力,避免全量拉取后计算 |
|
|
98
|
+
| 需要把多维表格当运行数据源单向读,且数据量大(要分片/多次分页/多表)或查询复杂/需持续同步 | 规划为全栈 + Base → 应用数据库单向同步(`lark-cli apps +db-sync-create`,streaming 持续增量 / batch 只导一次),运行时不走插件全量读 |
|
|
98
99
|
| 需要跨表关联、全文搜索、高频读写 | 规划同步至本地数据库后查询 |
|
|
99
100
|
| 外部系统 API | 明确接口、鉴权、网络可达性与失败策略 |
|
|
100
101
|
|
|
@@ -168,7 +168,9 @@ const structured = await capabilityClient
|
|
|
168
168
|
|
|
169
169
|
## 多维表格数据架构
|
|
170
170
|
|
|
171
|
-
应用需读取飞书多维表格数据时,按查询模式选架构(完整对照表见 `references/table.md`「数据架构选择」)。**快捷判断**:「仪表盘/dashboard/驾驶舱/看板/统计」→ 必须 `aggregateQuery`(禁止 searchRecords 全量拉取后内存计算);「列表/明细/详情」→ `searchRecords` 分页;「排行榜/TOP N」→ `searchRecords` + sort + pageSize=N;跨表关联或复杂计算 →
|
|
171
|
+
应用需读取飞书多维表格数据时,按查询模式选架构(完整对照表见 `references/table.md`「数据架构选择」)。**快捷判断**:「仪表盘/dashboard/驾驶舱/看板/统计」→ 必须 `aggregateQuery`(禁止 searchRecords 全量拉取后内存计算);「列表/明细/详情」→ `searchRecords` 分页;「排行榜/TOP N」→ `searchRecords` + sort + pageSize=N;需要把 Base 当运行数据源单向读、且数据量大到要分片/多次分页或查询复杂/需持续同步 → 先用 `lark-cli apps +db-sync-create` 把 Base 表同步成应用数据库表(streaming 持续增量 / batch 只导一次),页面查应用库;这是全栈应用的方案,配置格式和 `--preview`→`--yes` 流程见 lark-apps-db skill;跨表关联或复杂计算 → 应用数据库 + 同步。
|
|
172
|
+
|
|
173
|
+
**多维表格字段结构以插件为准**。lark-cli 读到的文本是字符串:链接和 @文档都写成 `[文字](链接)`,@人写成 `<at user_id=…>@名字</at>`;单选是数组。插件返回的是结构化字段,不能照 lark-cli 的输出定类型和渲染,各字段读写格式见 `references/table.md`「BizType 读写格式对照表」。Text 字段读取传 `textFormat: 'original'`,返回 `{ text, originalValue?: TextSegment[] }`,段 `type` 为 text / url / mention。渲染用 `BitableText`:有 `link` 就渲染成链接(url 段和 @文档的 mention 段都有);mention 段没有 `link` 的是 @人,按 `text` 显示、不可点;其余按 `text`。不要按 `type` 分支(mention 不等于 @人)。共享类型、AGENTS.md、派 Worker 的说明按此写。
|
|
172
174
|
|
|
173
175
|
## 外部服务插件规则(category=external-service 必读)
|
|
174
176
|
|
|
@@ -115,6 +115,21 @@ type TextSegment = {
|
|
|
115
115
|
};
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
### lark-cli 输出与插件分段的对照
|
|
119
|
+
|
|
120
|
+
lark-cli(`+record-list` / `+record-search`)读到的文本字段是字符串,链接和 @ 写成标记文本;插件返回的是上面的 `TextSegment` 数组。对应关系:
|
|
121
|
+
|
|
122
|
+
| lark-cli 读到的 | 插件 `originalValue` 里的段 | 页面展示 |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| 普通文字(可能含 `\n`) | `{ type: 'text', text }` | 文字,保留换行 |
|
|
125
|
+
| `[文字](https://…)`,普通超链接 | `{ type: 'url', text, link }` | `<a>` 可点击 |
|
|
126
|
+
| `[文字](https://…)`,@文档 | `{ type: 'mention', mentionType: 'Docx' / 'Wiki' / 'Bitable' 等, text, link, token }` | `<a>` 可点击 |
|
|
127
|
+
| `<at user_id=ou_…>@名字</at>`,@人 | `{ type: 'mention', mentionType: 'User', text: '@名字', token: 'ou_…' }`,没有 `link` | 文字,不可点击 |
|
|
128
|
+
|
|
129
|
+
- lark-cli 里普通超链接和 @文档写法一样,插件里分别是 url 段和 mention 段。渲染只看 `link`:有就渲染成链接,没有就按 `text` 显示。不要按 `type` 或 `mentionType` 分支。
|
|
130
|
+
- @人的 `ou_…` 按开放平台应用隔离:同一个人在 lark-cli 和插件里可能是不同的 id,不要跨来源匹配。
|
|
131
|
+
- 其他字段也不一样(如单选在 lark-cli 是数组、插件是字符串)。字段结构以上面的「BizType 读写格式对照表」为准,不要从 lark-cli 的输出推。
|
|
132
|
+
|
|
118
133
|
兼容说明:`originalValue` 是可选的新增字段,不传 `textFormat` 时返回内容与原来完全一致,只读 `.text` 的存量代码不用改也能正常运行。要链接可点击、保留换行时,再按下文迁移:读取加 `textFormat: 'original'`,展示从 `.text` 换成 `BitableText`。写入同理:Text 字段继续传字符串的存量代码行为不变,分段数组是新增的可选写法。
|
|
119
134
|
|
|
120
135
|
### 只读字段
|
|
@@ -161,6 +176,7 @@ type TextSegment = {
|
|
|
161
176
|
❌ searchRecords 拉全量 → 内存 reduce/filter 做统计 → 必须用 aggregateQuery
|
|
162
177
|
❌ searchRecords 拉全量 → sort → slice 取 TOP N → 必须用 searchRecords + sort + pageSize
|
|
163
178
|
❌ searchRecords 拉全量 → find 找单条 → 必须用 getRecord
|
|
179
|
+
❌ 页面每次加载分片拉全量 Base → 浏览器汇总 → 大数据量只读应同步到应用数据库(见下方「数据架构选择」)
|
|
164
180
|
```
|
|
165
181
|
|
|
166
182
|
## 数据架构选择(应用读取多维表格数据时)
|
|
@@ -174,9 +190,17 @@ type TextSegment = {
|
|
|
174
190
|
| 统计聚合(计数/求和/平均) | **纯插件 + aggregateQuery** | 禁止 searchRecords 全量拉取后内存计算 |
|
|
175
191
|
| 统计 + 明细下钻 | 纯插件 | 聚合用 `aggregateQuery`,下钻用 `searchRecords` + filter |
|
|
176
192
|
| 排行榜 / TOP N | 纯插件 | `searchRecords` + sort + pageSize=N |
|
|
177
|
-
|
|
|
193
|
+
| 需要把 Base 当运行数据源单向读(不在 Base 上写回),且数据量大(需分片/多次分页/多表)或查询复杂/需持续同步 | **全栈 + Base → 应用数据库单向同步** | 用 `lark-cli apps +db-sync-create` 把源表同步成应用库表:`mode=streaming` 持续增量、`mode=batch` 只导一次;页面和服务端查应用数据库,不要每次加载用插件全量拉 Base。配置格式、字段映射和 `--preview`→`--yes` 流程见 lark-apps-db skill |
|
|
194
|
+
| 复杂排序/多表关联/全文搜索 | **Base → 应用数据库单向同步** | 优先 `+db-sync-create`;同步表达不了映射时再自写定时/Webhook 同步,复杂查询走数据库 |
|
|
178
195
|
| 高频写入 + 读取 | 本地数据库为主 | 多维表格仅作展示/备份 |
|
|
179
196
|
|
|
197
|
+
> 单向同步只解决 Base → 应用数据库,应用侧修改不回写 Base;需要回写仍走插件。多环境应用同步建新表时必须显式 `--environment dev`(online 禁 DDL)。
|
|
198
|
+
|
|
199
|
+
运行时读取要求:
|
|
200
|
+
|
|
201
|
+
- 页面不要每次加载全量拉 Base:必须服务端分页或用 `aggregateQuery`,只把当前页/聚合结果传到浏览器。
|
|
202
|
+
- 首屏可以先展示缓存或上一次结果,但要标明数据时间;后台刷新失败时保留旧数据并提示,不要整页失败。
|
|
203
|
+
|
|
180
204
|
## 数据分析类Action补充说明
|
|
181
205
|
|
|
182
206
|
`aggregateQuery` 用于分组聚合统计,支持 dimensions(分组维度)和 measures(聚合计算)。
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tag-agent-task
|
|
3
|
+
description: "Use when 应用运行时需要把一件事派发给 Tag 智能体员工(AE)去做:服务端通过 PlatformHttpClientService 调平台集成接口 tag.create_agent_task 创建 Agent 任务,Agent 的飞书机器人在用户与 Agent 的飞书私聊(或新建群)里发出任务卡片,返回 task_id、chat_id 与飞书链接 url。覆盖请求/响应合同、给人看的任务卡片(title / summary / link_url)、client_token 幂等重试、错误码处理、NestJS service + controller 与前端调用示例。不用于 Agent 自己在开发期执行任务,也不用于插件(capabilityClient)或自动化触发器。触发词:派发任务, 派单, 交给 Agent, 交给智能体, 智能体员工, AE, Tag Agent, Tag 智能体, 让 Agent 去做, 创建 Agent 任务, create_agent_task, 在飞书中查看"
|
|
4
|
+
steering: true
|
|
5
|
+
steering-topic: tag_agent_task
|
|
6
|
+
match-template-name: nestjs-react-fullstack
|
|
7
|
+
available-request-source:
|
|
8
|
+
- workmate
|
|
9
|
+
- doubao_workmate
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# 派发任务给 Tag 智能体员工
|
|
13
|
+
|
|
14
|
+
应用(运行态)把一段自然语言任务交给搭建本应用的 Tag 智能体员工(AE)执行:Agent 的飞书机器人在它和当前用户的**飞书私聊**里发一张任务卡片(传 `create_group` 时改为新建一个群),然后按这条任务开始做。调用**立即返回** `task_id`、`chat_id` 和飞书链接 `url`,不会等 Agent 做完;用户点链接在飞书里看进展、接着和 Agent 对话。
|
|
15
|
+
|
|
16
|
+
## 使用边界
|
|
17
|
+
|
|
18
|
+
| 场景 | 做法 |
|
|
19
|
+
|------|------|
|
|
20
|
+
| 页面上「交给 Agent 处理」「派单给智能体」之类的按钮 / 业务流程 | 用本 skill:前端 → 应用后端接口 → `tag.create_agent_task` |
|
|
21
|
+
| 你(开发期 Agent)自己要做的事 | 直接做,不要生成调用本接口的代码 |
|
|
22
|
+
| 飞书消息 / 多维表格 / AI 生文等插件能力 | 走 plugin-guide |
|
|
23
|
+
| 定时 / 数据变更自动派单 | 触发器用 trigger-guide;但触发器入口**没有登录用户**,本接口会失败(见下文「操作人只能是当前登录用户」) |
|
|
24
|
+
| 查询任务进度、列表、取消 | **暂无接口**(GetAgentTask / ListAgentTasks / CancelAgentTask 后续提供),不要轮询、不要自造 |
|
|
25
|
+
|
|
26
|
+
## 接口合同
|
|
27
|
+
|
|
28
|
+
只能在 NestJS **服务端**调用。用 `@lark-apaas/fullstack-nestjs-core` 导出的 `PlatformHttpClientService`(`PlatformModule` 是全局模块,直接构造函数注入),通过 **`platformHttp.instance`** 发请求。不要用 `fetch` / `axios` / `rawInstance` 直连,不要手写鉴权或泳道 header;`instance` 会自动带当前请求的登录身份、SDK 请求日志与正常的请求上下文。
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const response = await this.platformHttp.instance.post(
|
|
32
|
+
'/api/v1/studio/innerapi/integration_apis/call',
|
|
33
|
+
{ apiName: 'tag.create_agent_task', input }, // 不传 userID:操作人由平台按登录态识别
|
|
34
|
+
{ timeout: 30000 },
|
|
35
|
+
);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
POST /api/v1/studio/innerapi/integration_apis/call
|
|
40
|
+
{
|
|
41
|
+
"apiName": "tag.create_agent_task",
|
|
42
|
+
"input": {
|
|
43
|
+
"content": "<给 Agent 看的完整任务描述>",
|
|
44
|
+
"client_token": "<uuid,同一次用户操作的重试复用同一个>",
|
|
45
|
+
"title": "<应传:卡片标题,这一单是什么>",
|
|
46
|
+
"summary": "<应传:卡片里给人看的一两句话>",
|
|
47
|
+
"link_url": "<可选:https 业务页面链接>"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| input 字段 | 必填 | 说明 |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `content` | 是 | 交给 Agent 的完整任务描述,写法见下方「content 必须写完整」 |
|
|
55
|
+
| `client_token` | 是 | 幂等键,每次**用户操作**生成一个 UUID;超时 / 5xx / 限流后重试必须复用,避免重复建任务 |
|
|
56
|
+
| `title` | **应传**(接口选填) | 卡片标题(≤ 50 字,超长截断),说清这一单是什么,如「工单 OC-0014 待排查」;不传只显示通用的「来自 <应用名> 的任务」,用户在飞书里分不清是哪一单 |
|
|
57
|
+
| `summary` | **应传**(接口选填) | 卡片里给人看的一两句说明(≤ 300 字,超长截断),如「统计页每日完成数与待办列表对不上,请排查」;不传卡片就没有正文 |
|
|
58
|
+
| `link_url` | 否 | 卡片「查看详情」按钮跳转的业务页面(如本应用里这条记录的详情页),必须是 `https://` 绝对地址,否则报参数错误 |
|
|
59
|
+
| `create_group` | 否 | `{ name?, disable_reply_topic? }`:传了就新建一个群来跑(用户任群主),**只在界面上明确要「建群处理」时才传**;不传在私聊里跑 |
|
|
60
|
+
|
|
61
|
+
成功输出:`{ task_id, url, chat_id }`:
|
|
62
|
+
- `url`:飞书链接,打开任务所在的会话并定位到任务卡片,展示给用户。
|
|
63
|
+
- `task_id`:任务卡片那条飞书消息的 ID(`om_` 开头),按不透明字符串保存。
|
|
64
|
+
- `chat_id`:任务所在的飞书会话(私聊或新建的群)。
|
|
65
|
+
|
|
66
|
+
### 卡片给人看,content 给 Agent 看
|
|
67
|
+
|
|
68
|
+
- 平台**每次派单都会发卡片**,用户在飞书里看到的就是它:标题、`summary`、一行「来自妙搭应用「<应用名>」」、可选的「查看详情」按钮。卡片给人看,`content` 给 Agent 看,所以实现时**应当传 `title` 和 `summary`**(按这一单的业务数据生成),有对应业务页面就再传 `link_url`;只传 `content` 时卡片只有通用标题,用户分不清是哪一单。
|
|
69
|
+
- **不要自己拼飞书卡片 JSON**,也不要把 `content` 原样塞进 `summary`。
|
|
70
|
+
- `summary` 写人看得懂的一两句话(这单是什么、为什么派),**不要**放记录 ID、回写指引这类给 Agent 的内容;那些写进 `content`。
|
|
71
|
+
- Agent 只读 `content`,读不到卡片里的文字,所以 `content` 仍要按下一节写完整。
|
|
72
|
+
|
|
73
|
+
### content 必须写完整
|
|
74
|
+
|
|
75
|
+
Agent 拿到的**只有** `content` 这段文字:看不到应用页面、不知道用户点的是哪条数据,也不会追问。实现时由代码把下面这些拼进 `content`,不要只传一句「处理这条记录」:
|
|
76
|
+
|
|
77
|
+
- **做什么、交付什么**:任务目标、期望产出、怎样算完成。
|
|
78
|
+
- **数据的定位标识**:基于哪条数据派单,就把它的 ID 带上,Agent 才能找到原数据、做完后回写。
|
|
79
|
+
- 多维表格记录:`app_token`、`table_id`、`record_id` 都要给。
|
|
80
|
+
- 应用自己的数据库:表名 + 主键 ID。
|
|
81
|
+
- 相关链接(记录链接、文档链接等)。
|
|
82
|
+
- **数据内容本身**:把关键字段的值写进去(标题、描述、负责人、截止时间等),不要指望 Agent 自己去查。
|
|
83
|
+
- **怎么回报进展**:应用如果提供了可供 Agent 调用的能力(如本应用的 MCP 工具、回写接口),写明工具名、要传的 ID 和字段,让 Agent 有进展或完成时调用它更新状态;没有就不写,不要编造工具名。本应用的 MCP 工具要在应用发布后 Agent 才能调用。
|
|
84
|
+
|
|
85
|
+
推荐结构(在后端按记录 ID 查出数据后组装):
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
任务:<要 Agent 做的事>
|
|
89
|
+
来源:多维表格记录 record_id=<recXXXX>(app_token=<...>,table_id=<...>)
|
|
90
|
+
记录内容:
|
|
91
|
+
- 标题:<...>
|
|
92
|
+
- 描述:<...>
|
|
93
|
+
- 截止时间:<...>
|
|
94
|
+
期望产出:<交付物与完成标准>
|
|
95
|
+
进展回写:有进展或完成时,调用本应用 MCP 工具 `<工具名>`,传 record_id=<recXXXX> 和进展说明 / 结果。
|
|
96
|
+
相关人员(要某人做事时必写):<姓名>(LarkID=<larkId>):<要他做的事>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 响应解包
|
|
100
|
+
|
|
101
|
+
与官方插件一致,响应体是 `{ status_code, error_msg?, data: { success, output } }`:
|
|
102
|
+
|
|
103
|
+
1. HTTP 非 2xx:`post` 直接抛错(`HttpError`,带 `response?.status`;超时 / 网络错误时 `response` 为空)。
|
|
104
|
+
2. `status_code !== '0'`:平台级错误(网关 / 鉴权),按服务端错误处理。
|
|
105
|
+
3. `data.success !== true`:调用失败,`data.output` 是 `{ code, message, detail }`:Tag 业务错误时 `detail.code` 是 Tag 错误码(如 `100004003`)、`detail.retryable` 表示可否重试;平台访问 Tag 超时 / 不可用时 `code` 为 `Unavailable`(无 `detail.code`),按服务端错误重试。
|
|
106
|
+
4. 否则 `data.output` 即 `{ task_id, url, chat_id }`。
|
|
107
|
+
|
|
108
|
+
### 错误码
|
|
109
|
+
|
|
110
|
+
| Tag 错误码 | 含义 | 处理 |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `100004001` | 参数错误 | 修正参数,**不重试**;返回 400 |
|
|
113
|
+
| `100004002` | 智能体不存在 | 不重试;提示目标智能体不存在 |
|
|
114
|
+
| `100004003` | 无权限:当前用户不能使用该智能体(不是它的主人 / 项目成员,或不在可协作范围内、智能体没开「允许团队外的人私聊」)/ 租户不匹配 | 不重试;给用户清晰提示(见示例),**不要返回 403**(前端 403 会被统一拦成「无操作权限,请联系管理员分配角色」,误导用户) |
|
|
115
|
+
| `100004006` | 限流(每个智能体 30 次/分钟) | 返回 429,提示稍后再试;用户重试时**复用同一个 client_token** |
|
|
116
|
+
| `100005900` | 飞书接口失败(机器人联系不到用户 / 建群失败) | 复用 client_token **只重试一次**;仍失败返回 503,提示「智能体暂时联系不到你,请稍后再试」 |
|
|
117
|
+
| `100006006`、其他 `100005xxx` / `100006xxx`、`output.code === 'Unavailable'`、超时 / 网络错误 | 服务端错误 | 退避重试,复用 client_token;仍失败返回 503 |
|
|
118
|
+
| 其他 | — | 不重试 |
|
|
119
|
+
|
|
120
|
+
### 操作人只能是当前登录用户
|
|
121
|
+
|
|
122
|
+
派单人(操作人)**永远是当前登录的用户**,由平台从登录态识别:`PlatformHttpClientService` 发请求时会自动带上当前请求的登录身份,平台只认这个。所以:
|
|
123
|
+
|
|
124
|
+
- 请求体**不要传 `userID`**,也不要想办法指定别的操作人;传了也不作数,平台仍按登录用户派单。不能「替别人派单」。
|
|
125
|
+
- 必须在**处理登录用户这次请求的调用链里**调用(Controller → Service 一路 `await` 下去)。应用关闭了登录、匿名访问、触发器 / Webhook / OpenAPI Key 入口、定时任务、脱离请求的后台队列都没有登录用户,调用会返回 `Unauthenticated`——此时直接给出明确提示(「需要登录后才能派发任务」),不要伪造或硬编码用户 ID。
|
|
126
|
+
- 当前登录用户就是这个智能体本身时(不能给自己派单)返回参数错误,按 `100004001` 同样处理即可。
|
|
127
|
+
|
|
128
|
+
### 要让某个人做事:content 里写明此人的 LarkID
|
|
129
|
+
|
|
130
|
+
功能是「让某个人去做某件事」(如指派给负责人、让审批人处理、通知某位同事跟进)时,操作人仍是当前登录用户(平台自动带上,不用传);**被指派的那个人**要写进 `content`,并带上他的 **LarkID**,只写姓名 Agent 找不到人:
|
|
131
|
+
|
|
132
|
+
- **LarkID** 是飞书全局用户 ID(纯数字字符串)。这里是 LarkID 的**唯一用途例外**:Tag Agent 认的就是它,不要换成 `employee_id`、`open_id` 或妙搭 userId。
|
|
133
|
+
- 手里是妙搭 userId:`AuthNPaasService.getBatchLarkIds([userId])`(见 user-identity)。
|
|
134
|
+
- 来自人员选择器 / 搜人:结果里的 `lark_id` 直接用;只有 `employee_id` 时先 `getBatchMiaodaUserIds` 转妙搭 userId,再 `getBatchLarkIds`。
|
|
135
|
+
- 不用写租户 ID:被指派人和操作人同租户,平台已经把租户带给 Tag。
|
|
136
|
+
- 按**字符串**原样拼进去,不要 `Number()` / `parseInt()`(大整数会丢精度,派错人)。
|
|
137
|
+
- 取不到此人的 LarkID(外部用户、转换返回 `null` 等)时,不要省略或编造,直接给用户明确提示,不发起派单。
|
|
138
|
+
- 写法:`相关人员:<姓名>(LarkID=<larkId>):<要他做的事>`;多个人逐行列出。
|
|
139
|
+
|
|
140
|
+
## 规则
|
|
141
|
+
|
|
142
|
+
- 只在服务端调用;前端只调应用自己的后端接口(如 `POST /api/agent-tasks`)。
|
|
143
|
+
- 操作人就是当前登录用户:请求体不传 `userID`,前端也不能传入或指定操作人;接口加 `@NeedLogin()`。
|
|
144
|
+
- 后端接口校验前端传入的 `content` / 业务 ID:`content` 非空、去首尾空白、限制长度;`title` / `summary` 去首尾空白、非空;`clientToken` 校验为 UUID;`linkUrl` 只接受 `https://`。
|
|
145
|
+
- `client_token` 每次用户操作生成一次(前端 `crypto.randomUUID()`),失败重试复用,成功后再换新。
|
|
146
|
+
- 成功后提示「已在飞书里发起,点此查看」,链接用返回的 `url`(新窗口打开)。
|
|
147
|
+
- 不要轮询任务状态,不要把 `url` 当作完成标志;如需留痕,把 `task_id`、`chat_id`、`url` 存进业务表。
|
|
148
|
+
- 日志里不要打印完整 `content`(可能含业务敏感数据),记 `task_id` 与 logid 即可。
|
|
149
|
+
|
|
150
|
+
## 示例代码
|
|
151
|
+
|
|
152
|
+
完整的 NestJS 服务端(类型、Service 含退避重试与错误映射、Controller、Module)与前端调用示例见 [references/example.md](references/example.md)。写代码前先读它,按其中的响应解包与错误处理实现,不要自行简化掉重试或 client_token 复用。
|
|
153
|
+
|
|
154
|
+
示例里前端直接传 `content` 只是演示调用链;基于业务数据(如多维表格记录)派单时,应改为前端只传记录 ID 等标识,后端查出数据后按「content 必须写完整」的推荐结构组装 `content`。
|
|
155
|
+
|
|
156
|
+
## 自检
|
|
157
|
+
|
|
158
|
+
- [ ] 只有服务端调用 `integration_apis/call`,前端只调 `/api/agent-tasks`
|
|
159
|
+
- [ ] 用的是 `platformHttp.instance`(不是 `fetch` / `axios` / `rawInstance`),没有手写鉴权或泳道 header
|
|
160
|
+
- [ ] 请求体没有传 `userID`;在登录用户请求的调用链里调用,接口加了 `@NeedLogin()`,无登录用户时有明确提示
|
|
161
|
+
- [ ] 功能是让某个人做事时,`content` 里写了此人的 LarkID(字符串,不是 employee_id / open_id / 妙搭 userId),取不到时明确提示、不派单
|
|
162
|
+
- [ ] `content` 带上了数据的定位 ID(如多维表格 `record_id`)、关键字段内容、期望产出,以及应用有回写能力时的进展回写方式
|
|
163
|
+
- [ ] 传了 `title` 和 `summary`(按这一单的业务数据生成),有业务页面时传了 `link_url`;没有自己拼卡片 JSON;`summary` 里没有给 Agent 的 ID 和指令
|
|
164
|
+
- [ ] `client_token` 按用户操作生成、重试复用
|
|
165
|
+
- [ ] `100004003` 没有映射成 403;限流返回 429;`100005900` 只重试一次;只对服务端错误 / 网络错误退避重试
|
|
166
|
+
- [ ] 成功后展示「已在飞书里发起,点此查看」链接,没有轮询任务状态
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# 派发任务给 Tag 智能体员工:完整示例
|
|
2
|
+
|
|
3
|
+
## 服务端示例
|
|
4
|
+
|
|
5
|
+
`shared/api.interface.ts`:
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
export interface CreateAgentTaskRequest {
|
|
9
|
+
content: string; // 给 Agent 看
|
|
10
|
+
clientToken: string;
|
|
11
|
+
title: string; // 以下给人看(飞书卡片):这一单是什么
|
|
12
|
+
summary: string; // 一两句说明
|
|
13
|
+
linkUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CreateAgentTaskResponse {
|
|
17
|
+
taskId: string; // 飞书消息 ID(om_),不透明字符串
|
|
18
|
+
chatId: string;
|
|
19
|
+
url: string; // 飞书链接
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`server/modules/agent-task/agent-task.service.ts`:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import {
|
|
27
|
+
BadRequestException,
|
|
28
|
+
HttpException,
|
|
29
|
+
HttpStatus,
|
|
30
|
+
Injectable,
|
|
31
|
+
Logger,
|
|
32
|
+
NotFoundException,
|
|
33
|
+
ServiceUnavailableException,
|
|
34
|
+
UnauthorizedException,
|
|
35
|
+
UnprocessableEntityException,
|
|
36
|
+
} from '@nestjs/common';
|
|
37
|
+
import { PlatformHttpClientService } from '@lark-apaas/fullstack-nestjs-core';
|
|
38
|
+
import type { CreateAgentTaskRequest, CreateAgentTaskResponse } from '@shared/api.interface';
|
|
39
|
+
|
|
40
|
+
interface IntegrationApiBody<T> {
|
|
41
|
+
status_code: string;
|
|
42
|
+
error_msg?: string;
|
|
43
|
+
data?: { success: boolean; output: T };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface TagTaskOutput {
|
|
47
|
+
task_id: string;
|
|
48
|
+
url: string;
|
|
49
|
+
chat_id: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 飞书接口失败(机器人联系不到用户 / 建群失败),Tag 建议只重试一次
|
|
53
|
+
const FEISHU_FAILED_CODE = '100005900';
|
|
54
|
+
|
|
55
|
+
const RETRY_DELAYS_MS = [1000, 3000];
|
|
56
|
+
|
|
57
|
+
@Injectable()
|
|
58
|
+
export class AgentTaskService {
|
|
59
|
+
private readonly logger = new Logger(AgentTaskService.name);
|
|
60
|
+
|
|
61
|
+
constructor(private readonly platformHttp: PlatformHttpClientService) {}
|
|
62
|
+
|
|
63
|
+
// loginUserId 只用来提前拦掉未登录的请求;操作人由平台按本次请求的登录态识别,请求体不传 userID。
|
|
64
|
+
// 必须在处理登录用户请求的调用链里 await 调用,不要丢进定时任务 / 后台队列。
|
|
65
|
+
async create(loginUserId: string | undefined, task: CreateAgentTaskRequest): Promise<CreateAgentTaskResponse> {
|
|
66
|
+
if (!loginUserId) {
|
|
67
|
+
throw new UnauthorizedException('需要登录后才能派发任务');
|
|
68
|
+
}
|
|
69
|
+
for (let attempt = 0; ; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const output = await this.callOnce(task);
|
|
72
|
+
this.logger.log(`agent task created: task_id=${output.task_id}`);
|
|
73
|
+
return { taskId: String(output.task_id), chatId: output.chat_id, url: output.url };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
const code = err instanceof TagCallError ? err.tagCode : undefined;
|
|
77
|
+
const retryable = err instanceof TagCallError ? err.retryable : isNetworkError(err);
|
|
78
|
+
const maxRetries = code === FEISHU_FAILED_CODE ? 1 : RETRY_DELAYS_MS.length;
|
|
79
|
+
if (retryable && attempt < maxRetries) {
|
|
80
|
+
this.logger.warn(`create_agent_task retry #${attempt + 1}: ${message}`);
|
|
81
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt]));
|
|
82
|
+
continue; // 复用同一个 clientToken
|
|
83
|
+
}
|
|
84
|
+
this.logger.error(`create_agent_task failed: ${message}`);
|
|
85
|
+
throw toHttpException(code, message, retryable);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async callOnce(task: CreateAgentTaskRequest): Promise<TagTaskOutput> {
|
|
91
|
+
const response = await this.platformHttp.instance.post(
|
|
92
|
+
'/api/v1/studio/innerapi/integration_apis/call',
|
|
93
|
+
{
|
|
94
|
+
apiName: 'tag.create_agent_task',
|
|
95
|
+
input: {
|
|
96
|
+
// 给 Agent 看,要写完整;要某人做事时写明此人的 LarkID
|
|
97
|
+
content: task.content,
|
|
98
|
+
client_token: task.clientToken,
|
|
99
|
+
// 给人看的卡片:平台按这几个字段生成极简卡片,不要自己拼卡片 JSON
|
|
100
|
+
title: task.title,
|
|
101
|
+
summary: task.summary,
|
|
102
|
+
link_url: task.linkUrl,
|
|
103
|
+
// 只有界面上明确要「建群处理」时才加:create_group: { name: '<群名>' },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{ timeout: 30000 },
|
|
107
|
+
);
|
|
108
|
+
const body = (await response.json()) as IntegrationApiBody<TagTaskOutput>;
|
|
109
|
+
if (body.status_code !== '0') {
|
|
110
|
+
// 平台级错误(网关 / 鉴权),按服务端错误重试
|
|
111
|
+
throw new TagCallError(`platform error ${body.status_code}: ${body.error_msg ?? ''}`, undefined, true);
|
|
112
|
+
}
|
|
113
|
+
if (body.data?.success !== true) {
|
|
114
|
+
const out = (body.data?.output ?? {}) as unknown as FailureOutput;
|
|
115
|
+
const tagCode = out.detail?.code !== undefined ? String(out.detail.code) : undefined;
|
|
116
|
+
// Tag 业务错误看 detail.retryable;平台访问 Tag 超时 / 不可用(code=Unavailable)按服务端错误重试
|
|
117
|
+
// 限流(100004006)不在服务端重试,交给用户稍后再发(复用同一个 client_token)
|
|
118
|
+
const retryable = tagCode
|
|
119
|
+
? tagCode !== '100004006' && (out.detail?.retryable === true || isServerError(tagCode))
|
|
120
|
+
: out.code === 'Unavailable';
|
|
121
|
+
throw new TagCallError(out.message ?? JSON.stringify(out), tagCode, retryable);
|
|
122
|
+
}
|
|
123
|
+
return body.data.output;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface FailureOutput {
|
|
128
|
+
code?: string;
|
|
129
|
+
message?: string;
|
|
130
|
+
detail?: { code?: string | number; message?: string; retryable?: boolean };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class TagCallError extends Error {
|
|
134
|
+
constructor(message: string, readonly tagCode: string | undefined, readonly retryable: boolean) {
|
|
135
|
+
super(message);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isServerError(code?: string): boolean {
|
|
140
|
+
return !!code && (code.startsWith('100005') || code.startsWith('100006'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isNetworkError(err: unknown): boolean {
|
|
144
|
+
// HttpError 没有 response = 超时 / 网络错误;有 response 且 5xx 也视为可重试
|
|
145
|
+
const status = (err as { response?: { status?: number } })?.response?.status;
|
|
146
|
+
return (err as { name?: string })?.name === 'HttpError' && (status === undefined || status >= 500);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toHttpException(code: string | undefined, message: string, retryable: boolean): HttpException {
|
|
150
|
+
switch (code) {
|
|
151
|
+
case '100004001':
|
|
152
|
+
return new BadRequestException('任务参数不合法,请检查任务内容(也不能给智能体自己派单)');
|
|
153
|
+
case '100004002':
|
|
154
|
+
return new NotFoundException('目标智能体不存在');
|
|
155
|
+
case '100004003':
|
|
156
|
+
return new UnprocessableEntityException('你的账号暂时无法使用该智能体,请联系智能体的主人开通协作权限');
|
|
157
|
+
case '100004006':
|
|
158
|
+
return new HttpException('派发太频繁,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
|
|
159
|
+
case FEISHU_FAILED_CODE:
|
|
160
|
+
return new ServiceUnavailableException('智能体暂时联系不到你,请稍后再试');
|
|
161
|
+
default:
|
|
162
|
+
return retryable
|
|
163
|
+
? new ServiceUnavailableException('智能体服务暂时不可用,请稍后重试')
|
|
164
|
+
: new BadRequestException(`派发任务失败:${message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`server/modules/agent-task/agent-task.controller.ts`:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
import { Body, Controller, Post, Req } from '@nestjs/common';
|
|
173
|
+
import type { Request } from 'express';
|
|
174
|
+
import { NeedLogin } from '@lark-apaas/fullstack-nestjs-core';
|
|
175
|
+
import { IsNotEmpty, IsOptional, IsString, IsUrl, IsUUID, MaxLength } from 'class-validator';
|
|
176
|
+
import { Transform } from 'class-transformer';
|
|
177
|
+
import type { CreateAgentTaskRequest, CreateAgentTaskResponse } from '@shared/api.interface';
|
|
178
|
+
import { AgentTaskService } from './agent-task.service';
|
|
179
|
+
|
|
180
|
+
class CreateAgentTaskDto implements CreateAgentTaskRequest {
|
|
181
|
+
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
|
182
|
+
@IsString()
|
|
183
|
+
@IsNotEmpty()
|
|
184
|
+
@MaxLength(5000) // 业务上限示例;平台拒绝时会返回 100004001
|
|
185
|
+
content!: string;
|
|
186
|
+
|
|
187
|
+
@IsUUID()
|
|
188
|
+
clientToken!: string;
|
|
189
|
+
|
|
190
|
+
// 以下只用于给人看的飞书卡片(接口选填,但应当传,否则卡片只有通用标题);
|
|
191
|
+
// title 超 50 字、summary 超 300 字由平台截断,这里不拦
|
|
192
|
+
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
|
193
|
+
@IsString()
|
|
194
|
+
@IsNotEmpty()
|
|
195
|
+
title!: string;
|
|
196
|
+
|
|
197
|
+
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
|
198
|
+
@IsString()
|
|
199
|
+
@IsNotEmpty()
|
|
200
|
+
summary!: string;
|
|
201
|
+
|
|
202
|
+
@IsOptional()
|
|
203
|
+
@IsUrl({ protocols: ['https'], require_protocol: true })
|
|
204
|
+
linkUrl?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@Controller('api/agent-tasks')
|
|
208
|
+
export class AgentTaskController {
|
|
209
|
+
constructor(private readonly agentTaskService: AgentTaskService) {}
|
|
210
|
+
|
|
211
|
+
@NeedLogin()
|
|
212
|
+
@Post()
|
|
213
|
+
async create(@Req() req: Request, @Body() dto: CreateAgentTaskDto): Promise<CreateAgentTaskResponse> {
|
|
214
|
+
return this.agentTaskService.create(req.userContext.userId, dto);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`agent-task.module.ts` 里 `controllers: [AgentTaskController]`、`providers: [AgentTaskService]`,并在 `app.module.ts` 注册。`PlatformHttpClientService` 来自全局 `PlatformModule`,不需要再 import 或自建 provider。
|
|
220
|
+
|
|
221
|
+
## 前端示例(实际按 coding-guide 用 shadcn Button / toast,这里只示意调用与 token 复用)
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
import { useRef, useState } from 'react';
|
|
225
|
+
import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBackend';
|
|
226
|
+
import type { CreateAgentTaskResponse } from '@shared/api.interface';
|
|
227
|
+
|
|
228
|
+
// content 给 Agent 看(要写完整);title / summary 给人看(飞书卡片),按这一单的业务数据生成
|
|
229
|
+
export function DispatchToAgentButton({ content, title, summary }: { content: string; title: string; summary: string }) {
|
|
230
|
+
const tokenRef = useRef<string | null>(null);
|
|
231
|
+
const [result, setResult] = useState<CreateAgentTaskResponse | null>(null);
|
|
232
|
+
const [error, setError] = useState('');
|
|
233
|
+
const [loading, setLoading] = useState(false);
|
|
234
|
+
|
|
235
|
+
const dispatch = async () => {
|
|
236
|
+
// 同一次操作失败后重试复用 token;成功后清空,下次操作生成新的
|
|
237
|
+
tokenRef.current ??= crypto.randomUUID();
|
|
238
|
+
setLoading(true);
|
|
239
|
+
setError('');
|
|
240
|
+
try {
|
|
241
|
+
const res = await axiosForBackend.post<CreateAgentTaskResponse>('/api/agent-tasks', {
|
|
242
|
+
content,
|
|
243
|
+
title,
|
|
244
|
+
summary,
|
|
245
|
+
// 示例:卡片「查看详情」回到当前页面;实际优先用这条记录的详情页地址。只接受 https,非 https 时不传,避免整单被拒
|
|
246
|
+
linkUrl: window.location.protocol === 'https:' ? window.location.href : undefined,
|
|
247
|
+
clientToken: tokenRef.current,
|
|
248
|
+
});
|
|
249
|
+
setResult(res.data);
|
|
250
|
+
tokenRef.current = null;
|
|
251
|
+
} catch (e: any) {
|
|
252
|
+
setError(e?.response?.data?.error?.message ?? '派发失败,请稍后重试');
|
|
253
|
+
} finally {
|
|
254
|
+
setLoading(false);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className="flex items-center gap-3">
|
|
260
|
+
<button disabled={loading} onClick={dispatch}>交给 Agent 处理</button>
|
|
261
|
+
{result && (
|
|
262
|
+
<a href={result.url} target="_blank" rel="noreferrer">已在飞书里发起,点此查看</a>
|
|
263
|
+
)}
|
|
264
|
+
{error && <span className="text-destructive">{error}</span>}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
```
|
|
@@ -44,7 +44,14 @@ ls -d node_modules/@lark-apaas/nestjs-mcp node_modules/@lark-apaas/coding-preset
|
|
|
44
44
|
| 用 `mcp_inspect` 核对与调用、按错误定位、本地模式调试 | `$SKILL_DIR/references/debugging.md` |
|
|
45
45
|
| 用户要在自己的 Agent(Claude Code / Codex / 其它客户端)里连本应用的 MCP | `$SKILL_DIR/references/client-onboarding.md` |
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
调用 `mcp_inspect` 前,先读 `$SKILL_DIR/references/debugging.md`。改工具、资源或界面后,等 dev server 重启再验证(定义有问题会在启动日志里报)。`mcp_inspect` 报「当前运行环境不支持」时,走该文件的本地模式。向用户汇报用业务表述(「找到 4 个演示航班」),不贴 JSON。
|
|
48
|
+
|
|
49
|
+
## 验证与汇报
|
|
50
|
+
|
|
51
|
+
- `mcp_inspect` 先用 `action: "list_tools"` 核对工具清单,再用 `action: "call"` 以真实入参验证所有只读工具。入参已确定的调用在同一轮发出,依赖前一步结果的调用后置。`tool_name` 取清单中的完整名,`arguments` 传 JSON 对象字符串(无入参用 `"{}"`)。正常调用应返回 `isError: false`,文本内容和 `structuredContent` 应包含预期业务数据;REST 接口或页面自测不能替代 MCP 调用。
|
|
52
|
+
- 写工具只在操作自建、且能自行删除或撤销的数据时传 `safe: true`;调用后读回确认变更并清理,汇报实际变更及清理情况。不能清理时只验证入参校验与业务错误分支,汇报「写入链路未真实验证」。
|
|
53
|
+
- 每个 `McpToolError` 分支构造一次触发,确认 `isError: true`,且 `code` 与工具描述一致。出现通用失败或结果不符合预期时按 `references/debugging.md` 定位,修复后复测失败项。
|
|
54
|
+
- 总结前对照工具清单与本轮实际调用结果,点名未调用的工具、未验证的分支和仍失败的项;只测了错误分支就只报错误分支通过,不写整个工具通过。有未覆盖项,不写「全部通过」。
|
|
48
55
|
|
|
49
56
|
## 不变量
|
|
50
57
|
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
1. 改工具 / 资源 / 界面。
|
|
25
25
|
2. 等 dev server 重启(`nest --watch` 会自动重启;界面改动由 `[mcp-ui]` 插件自动重建)。定义有问题会让应用起不来并打印「MCP 定义校验失败」,修完再往下走。
|
|
26
26
|
3. `list_tools` 核对清单与源码一致(`visibility: ['app']` 的工具也在清单里,属正常)。
|
|
27
|
-
4. `call`
|
|
27
|
+
4. `call` 以真实入参验证所有只读工具,调用顺序按 [顶层「验证与汇报」](../SKILL.md#验证与汇报) 组织。除了 `structuredContent`,单独看「文本内容」那段是否带全了业务数据:只读文本的客户端只看得到这段,只有「找到 N 条」之类的统计文案就是 `content` 写坏了(见 `tool-authoring.md`「方法签名与返回值」)。写工具只在能自己清掉痕迹(有对应删除 / 撤销工具,或业务上可撤回)时才真调并随手清理;否则只验证入参校验与 `McpToolError` 分支,汇报里写明「写入链路未真实验证」。
|
|
28
28
|
5. 带界面的工具 `read_resource`。
|
|
29
29
|
6. 每个 `McpToolError` 分支构造一次触发(不存在的 ID、非法状态),确认 `isError: true` 且 `code` 与 `description` 预告一致。
|
|
30
30
|
7. 更新 `server/mcp/skills/<name>/SKILL.md`,再 curl 目录接口(frontmatter 不合法这里会 500):`skills[]` 有该 `name`、`content` 是最新正文,`prompts[]` 的 `description` 一致。
|
|
@@ -77,6 +77,4 @@ curl -s -X POST 'http://localhost:<port><base>/__innerapi__/mcp' \
|
|
|
77
77
|
|
|
78
78
|
## 汇报
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
- 写工具真调过的,说明动了哪些数据、是否已清理;没真调的写明「写入链路未真实验证」。
|
|
82
|
-
- 未验证的分支明说未验证,不写「已全部通过」。
|
|
80
|
+
按 [SKILL.md 的「验证与汇报」](../SKILL.md#验证与汇报) 对照实际调用结果,说明验证范围与测试数据清理情况。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: trigger-guide
|
|
3
|
-
description: 自动化任务触发器配置与代码开发指南,支持 cron 定时触发器、record_change
|
|
3
|
+
description: 自动化任务触发器配置与代码开发指南,支持 cron 定时触发器、record_change 数据变更触发器、webhook 触发器和 feishu_approval 飞书审批触发器,包含 @Automation/@BindTrigger 装饰器用法和 Crontab 表达式规范。Use when 需要:(1) 创建或配置自动化任务/定时任务,(2) 编写 automation 代码绑定触发器,或其他自动化任务相关开发
|
|
4
4
|
steering: true
|
|
5
5
|
steering-topic: trigger_guide
|
|
6
6
|
match-template-name: nestjs-react-fullstack
|
|
@@ -45,19 +45,20 @@ server
|
|
|
45
45
|
|
|
46
46
|
### 触发器类型与入参
|
|
47
47
|
|
|
48
|
-
触发器类型(`triggerType
|
|
48
|
+
触发器类型(`triggerType`)有四种:
|
|
49
49
|
|
|
50
50
|
- `record_change`:记录变更触发器,**有入参**
|
|
51
51
|
- `cron`:定时触发器,**无入参**
|
|
52
52
|
- `webhook`:Webhook 触发器,**有入参**
|
|
53
|
+
- `feishu_approval`:飞书审批触发器,**有入参**
|
|
53
54
|
|
|
54
55
|
```typescript
|
|
55
|
-
//
|
|
56
|
+
// 有入参触发器的入参类型
|
|
56
57
|
interface TaskHandlerArgs {
|
|
57
58
|
attributes: {
|
|
58
59
|
trigger: string;
|
|
59
60
|
triggerID?: string;
|
|
60
|
-
triggerType: 'record_change' | 'cron' | 'webhook';
|
|
61
|
+
triggerType: 'record_change' | 'cron' | 'webhook' | 'feishu_approval';
|
|
61
62
|
instanceID: string;
|
|
62
63
|
startAt?: number;
|
|
63
64
|
};
|
|
@@ -81,6 +82,12 @@ interface DataChangeEventInput {
|
|
|
81
82
|
msg_id: string;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// feishu_approval:content.input 解析后的审批实例事件
|
|
86
|
+
type FeishuApprovalInstanceEventInput = {
|
|
87
|
+
uuid: string; event_type: string; app_id: string; approval_code: string;
|
|
88
|
+
instance_code: string; status: string; event_time: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
84
91
|
// webhook:input 解析后的数据结构
|
|
85
92
|
interface WebhookEvent {
|
|
86
93
|
method: 'GET' | 'POST';
|
|
@@ -102,6 +109,22 @@ function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
|
102
109
|
}
|
|
103
110
|
```
|
|
104
111
|
|
|
112
|
+
### 飞书审批实例事件
|
|
113
|
+
|
|
114
|
+
`feishu_approval` 的 `content.input` 是 JSON 字符串;解析后是上面的扁平审批实例事件,不是飞书原始 Webhook 外层,也不是实例详情响应。
|
|
115
|
+
|
|
116
|
+
- 审批未完成时由发起人撤回,审批实例事件状态为 `CANCELED`;`REVERTED` 用于撤销已通过的审批实例,不能外推到所有已结束状态。
|
|
117
|
+
- 获取单个审批实例详情时,详情 `status` 不返回 `REVERTED`,而以 `reverted`、`reverted_instance_code` 表达相关撤销信息;不得仅凭详情 `status` 排除事件状态。
|
|
118
|
+
- `approvalInstanceFilter.status` 是精确包含匹配的数组:事件状态命中任一元素即触发(OR),未列出的状态不触发。只有业务需要覆盖审批完成前取消和已通过后撤销两个阶段时才同时配置 `CANCELED`、`REVERTED`;只监听一个阶段时,单状态数组仍合法。
|
|
119
|
+
|
|
120
|
+
需要同时处理批准、拒绝和两个撤销阶段时,`triggerCondition` 可配置为:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{"approvalCode":"<审批定义唯一标识>","eventType":"approval_instance","approvalInstanceFilter":{"status":["APPROVED","REJECTED","CANCELED","REVERTED"]}}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
若项目代码、现有 filter 与外部说明冲突,先核验权威的 `approval_instance` 生命周期和事件合同:业务确需覆盖两个撤销阶段时补齐两个状态及对应处理逻辑;只针对一个阶段时保留合同对应的单状态监听,不要断言一个状态覆盖所有撤销场景。
|
|
127
|
+
|
|
105
128
|
### 指定值限制
|
|
106
129
|
1. Webhook 触发器不可以设置指定值,并且告知用户。
|
|
107
130
|
|
|
@@ -223,7 +246,7 @@ export class DemoAutomationTasksService {
|
|
|
223
246
|
- 需要查询数据库中的特定数据,给用户发消息:数据库中需要存储用户 id,使用从数据库中查询到的用户 id 进行后续操作
|
|
224
247
|
- 需要调用飞书能力给用户发消息:飞书能力不应该接受用户信息作为参数,而是应该在飞书能力配置里要求用户自己预先指定
|
|
225
248
|
|
|
226
|
-
2. 入参解析规范(仅 record_change 和
|
|
249
|
+
2. 入参解析规范(仅 record_change、webhook 和 feishu_approval 触发器):
|
|
227
250
|
- 有入参的触发器方法签名为 `async methodName(event: TaskHandlerArgs)`,`cron` 触发器无入参
|
|
228
251
|
- `content.input` 是 JSON 字符串,先用 `typeof input === 'string'` 检查类型,再用 `JSON.parse()` 解析,需添加 try-catch 错误处理
|
|
229
252
|
- `record_change`:根据操作类型获取数据:INSERT/UPDATE 使用 `after` 字段,DELETE 使用 `before` 字段
|
|
@@ -69,6 +69,8 @@ match-template-name: vite-react
|
|
|
69
69
|
- 构建搜索过滤条件(filter / conditions)时
|
|
70
70
|
- 使用多维表格相关 UI 组件(人员展示、超链接、单/多选下拉 等;vite-react 用 shadcn-ui 拼装)时
|
|
71
71
|
|
|
72
|
+
**多维表格字段结构以插件为准**。lark-cli 读到的文本是字符串:链接和 @文档都写成 `[文字](链接)`,@人写成 `<at user_id=…>@名字</at>`;单选是数组。插件返回的是结构化字段,不能照 lark-cli 的输出定类型和渲染,各字段读写格式见 `references/table.md`「BizType 读写格式对照表」。Text 字段读取传 `textFormat: 'original'`,返回 `{ text, originalValue?: TextSegment[] }`,段 `type` 为 text / url / mention。渲染用 `BitableText`:有 `link` 就渲染成链接(url 段和 @文档的 mention 段都有);mention 段没有 `link` 的是 @人,按 `text` 显示、不可点;其余按 `text`。不要按 `type` 分支(mention 不等于 @人)。类型定义、AGENTS.md、派 Worker 的说明按此写。
|
|
73
|
+
|
|
72
74
|
## Plugin 链式调用(Plugin Chain)
|
|
73
75
|
|
|
74
76
|
很多业务场景需要多个插件串联完成,**禁止用正则/字符串解析替代 AI 插件做结构化输出处理**(包括提取和生成场景)。
|
|
@@ -111,6 +111,21 @@ type TextSegment = {
|
|
|
111
111
|
};
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
### lark-cli 输出与插件分段的对照
|
|
115
|
+
|
|
116
|
+
lark-cli(`+record-list` / `+record-search`)读到的文本字段是字符串,链接和 @ 写成标记文本;插件返回的是上面的 `TextSegment` 数组。对应关系:
|
|
117
|
+
|
|
118
|
+
| lark-cli 读到的 | 插件 `originalValue` 里的段 | 页面展示 |
|
|
119
|
+
| --- | --- | --- |
|
|
120
|
+
| 普通文字(可能含 `\n`) | `{ type: 'text', text }` | 文字,保留换行 |
|
|
121
|
+
| `[文字](https://…)`,普通超链接 | `{ type: 'url', text, link }` | `<a>` 可点击 |
|
|
122
|
+
| `[文字](https://…)`,@文档 | `{ type: 'mention', mentionType: 'Docx' / 'Wiki' / 'Bitable' 等, text, link, token }` | `<a>` 可点击 |
|
|
123
|
+
| `<at user_id=ou_…>@名字</at>`,@人 | `{ type: 'mention', mentionType: 'User', text: '@名字', token: 'ou_…' }`,没有 `link` | 文字,不可点击 |
|
|
124
|
+
|
|
125
|
+
- lark-cli 里普通超链接和 @文档写法一样,插件里分别是 url 段和 mention 段。渲染只看 `link`:有就渲染成链接,没有就按 `text` 显示。不要按 `type` 或 `mentionType` 分支。
|
|
126
|
+
- @人的 `ou_…` 按开放平台应用隔离:同一个人在 lark-cli 和插件里可能是不同的 id,不要跨来源匹配。
|
|
127
|
+
- 其他字段也不一样(如单选在 lark-cli 是数组、插件是字符串)。字段结构以上面的「BizType 读写格式对照表」为准,不要从 lark-cli 的输出推。
|
|
128
|
+
|
|
114
129
|
兼容说明:`originalValue` 是可选的新增字段,不传 `textFormat` 时返回内容与原来完全一致,只读 `.text` 的存量代码不用改也能正常运行。要链接可点击、保留换行时,再按下文迁移:读取加 `textFormat: 'original'`,展示从 `.text` 换成 `BitableText`。写入同理:Text 字段继续传字符串的存量代码行为不变,分段数组是新增的可选写法。
|
|
115
130
|
|
|
116
131
|
### 只读字段
|