Files
upstream-twenty/packages/twenty-docs/l/zh/developers/extend/apps/logic/logic-functions.mdx
upstream-mirror e7da97194a
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

910 lines
45 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: 逻辑函数
description: 定义具有 HTTP、cron 和数据库事件触发器的服务端 TypeScript 函数。
icon: bolt
---
逻辑函数是在 Twenty 平台上运行的服务端 TypeScript 函数。 它们可以由 HTTP 请求、cron 调度或数据库事件触发——也可以作为工具暴露给 AI 智能体。
<AccordionGroup>
<Accordion title="defineLogicFunction" description="定义逻辑函数及其触发器">
每个函数文件都使用 `defineLogicFunction()` 导出包含处理程序和可选触发器的配置。
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const body = (params.body ?? {}) as { name?: string };
const name = body.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world';
const result = await client.mutation({
createPostCard: {
__args: { data: { name } },
id: true,
name: true,
},
});
return result;
};
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'POST',
isAuthRequired: true,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
},*/
/*cronTriggerSettings: {
pattern: '0 0 1 1 *',
},*/
});
```
可用的触发器类型:
* **httpRoute**:通过 HTTP 路径和方法公开你的函数。 在应用代码中,使用 `RestApiClient` 时,在路由路径前加上 `/s/` 前缀;已部署的 URL 使用注入的 `TWENTY_FUNCTIONS_URL` 基础地址(如果未设置,则使用 `\<server-url>/s`)。
<Note>
要从(无头)前端组件调用由路由触发的逻辑函数,请参见[调用逻辑函数](/l/zh/developers/extend/apps/layout/front-components#calling-a-logic-function)。
</Note>
* **cron**:使用 CRON 表达式按计划运行你的函数。
* **databaseEvent**:在工作区对象生命周期事件上运行。 当事件操作为 `updated` 时,可以在 `updatedFields` 数组中指定要监听的特定字段。 如果未定义或为空,任何更新都会触发该函数。
> 例如 `person.updated`、`*.created`、`company.*`
* **serverRoute**:公开一个注册作用域的单一 HTTP 路由。 一个在所有者工作区中运行的 **resolver** 函数(使用 `serverRouteTriggerSettings` 声明)要么返回一个同步的 `Response`,要么返回目标工作区以及要入队的逻辑函数;在入队路径下,平台会发送 `202` 确认并在工作队列上运行该**目标**。 参见 [服务端路由触发器](#server-route-trigger)。
<Note>
你也可以使用 CLI 手动执行函数:
```bash filename="Terminal"
yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
```
```bash filename="Terminal"
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
你可以通过以下方式查看日志:
```bash filename="Terminal"
yarn twenty dev:function:logs
```
</Note>
#### 路由触发器负载
当路由触发器调用你的逻辑函数时,它会接收一个遵循
[AWS HTTP API v2 格式](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)的 `RoutePayload` 对象。
从 `twenty-sdk/logic-function` 导入 `RoutePayload` 类型:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
const { method, path } = event.requestContext.http;
return { message: 'Success' };
};
```
`RoutePayload` 类型具有以下结构:
| 属性 | 类型 | 描述 | 示例 |
| ---------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP 请求头(仅限 `forwardedRequestHeaders` 中列出的那些) | 见下文 |
| `queryStringParameters` | `Record\<string, string \| undefined>` | 查询字符串参数(多个值以逗号连接) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | 从路由模式中提取的路径参数 | `/users/:id`,`/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | 已解析的请求体(JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | 在 JSON 解析之前的原始 UTF-8 请求体。 用于验证 HMAC 风格的 Webhook 签名(例如 GitHub 的 `X-Hub-Signature-256`、Stripe)。 当运行时未保留它时为 `undefined`。 | |
| `isBase64Encoded` | `boolean` | 请求体是否为 base64 编码 | |
| `requestContext.http.method` | `string` | HTTP 方法(GET、POST、PUT、PATCH、DELETE) | |
| `requestContext.http.path` | `string` | 原始请求路径 | |
#### forwardedRequestHeaders
出于安全原因,默认**不会**将传入请求的 HTTP 请求头传递给你的逻辑函数。
如需访问特定请求头,请在 `forwardedRequestHeaders` 数组中显式列出:
```ts
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'webhook-handler',
handler,
httpRouteTriggerSettings: {
path: '/webhook',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: ['x-webhook-signature', 'content-type'],
},
});
```
在你的处理程序中,可以这样访问被转发的请求头:
```ts
const handler = async (event: RoutePayload) => {
const signature = event.headers['x-webhook-signature'];
const contentType = event.headers['content-type'];
// Validate webhook signature...
return { received: true };
};
```
<Note>
请求头名称会被规范化为小写。 请使用小写键访问它们(例如,`event.headers['content-type']`)。
</Note>
#### 自定义 HTTP 响应
默认情况下,从处理程序返回一个普通值会以 `200` 响应返回该值(对象为 JSON,字符串为 `text/plain`)。 要控制状态码和响应头,请从 `twenty-sdk/logic-function` 返回一个 `Response`:
```ts
import { Response } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
return new Response('<h1>Hello</h1>', {
status: 201,
headers: { 'content-type': 'text/html' },
});
};
```
出于安全原因,响应头被限制在一个允许列表中。 任何不在该列表中的响应头(例如 `Set-Cookie`、CORS 响应头(如 `Access-Control-Allow-Origin`),或自定义的 `X-*` 响应头)都会在发送响应之前被静默丢弃。 允许的响应头包括:
* `content-type`
* `content-language`
* `content-disposition`
* `cache-control`
* `retry-after`
<Note>
状态码必须是有效的 HTTP 状态码(介于 100 和 599 之间)。 响应头名称的匹配不区分大小写。
</Note>
#### 平台错误响应
除了处理程序自身的响应之外,在某些情况下,平台会直接对路由调用做出响应:当路由或函数不存在时返回 `404`,当应用停止时返回 `403`,当触发执行速率限制时返回 `429`,以及当应用的生产环境 `dependencies` 过大而无法安装时返回 `422`——请参见[依赖项大小限制](/l/zh/developers/extend/apps/getting-started/project-structure#dependencies)。
#### 服务端路由触发器
`httpRouteTriggerSettings` 在 `/s/` 下暴露一个函数,并根据请求主机解析 workspace——这在每个 workspace 都有自己域名时有效。 然而,第三方服务商会将每个租户的事件发送到**同一个** URL。 在这种情况下,请使用 `serverRouteTriggerSettings`。
触发器由两部分组成:
1. 一个在 **所有者工作区**(拥有应用注册的工作区)中运行的 **resolver** 逻辑函数——使用 `serverRouteTriggerSettings` 声明。 它会检查传入请求并返回以下两者之一:
* `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — 平台会在解析得到的工作区中将该目标入队,并返回 `202 { queued: true }` 确认,或者
* 来自 `twenty-sdk/logic-function` 的 `Response` — 平台会**同步**回显该 HTTP 响应,并且**不会**入队目标(将其用于诸如 Slack `url_verification` 之类的挑战握手)。
resolver 是唯一的授权点——URL 只携带 resolver 的标识符。 **这里是验证请求签名的首选位置**:resolver 在任何副作用之前运行,可以访问原始的 `rawBody` 和转发的请求头,并且可以在不触及目标的情况下直接拒绝请求。
2. 一个 **target** 逻辑函数——一个常规的、按工作区划分的逻辑函数——随后在解析得到的工作区中运行,并使用 resolver 返回的负载(如果 resolver 未对其进行转换,则使用原始请求负载)。 当 resolver 选择入队(enqueue)路径时,其返回值 **不会** 被 HTTP 调用方观察到。
```ts src/logic-functions/resolve-server-route.logic-function.ts
import { createHmac, timingSafeEqual } from 'crypto';
import { defineLogicFunction } from 'twenty-sdk/define';
import { Response, type RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the owner workspace. Verifies the request signature, picks
// which target function should handle the event, and returns the
// workspace + target the platform should dispatch to.
const handler = async (event: RoutePayload) => {
// Fail closed if the secret isn't configured — never fall back to an
// empty key, which would let any caller forge a matching signature.
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
}
const signature = event.headers['x-hub-signature-256'] ?? '';
const expected =
'sha256=' +
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error('invalid signature');
}
const body = (event.body ?? {}) as {
challenge?: string;
metadata?: { twentyWorkspaceId?: string };
type?: string;
};
// Handshakes must be answered on this same response, so reply from the
// resolver instead of returning a dispatch target.
if (body.type === 'url_verification') {
return new Response({ challenge: body.challenge });
}
const workspaceId = body.metadata?.twentyWorkspaceId;
if (!workspaceId) {
throw new Error('event is not linked to a workspace');
}
return {
workspaceId,
// Route different event types to different target functions.
targetLogicFunctionUniversalIdentifier:
body.type === 'invoice.paid'
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
};
};
export default defineLogicFunction({
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'resolve-server-route',
handler,
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
},
});
```
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the resolved workspace. The resolver has already authenticated
// the request, so this handler can focus on the actual work.
const handler = async (event: RoutePayload) => {
// ...handle the verified event
return { received: true };
};
export default defineLogicFunction({
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'handle-invoice-paid',
handler,
});
```
该端点可在以下地址访问:
```
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
```
该标识符是清单(manifest)中 resolver 的 `universalIdentifier`。 在服务商处注册该 URL。
**响应验证 GET 请求。** 某些提供商会在向端点传递内容之前对其进行验证,方法是向其稍后将 `POST` 事件到的同一 URL 发送携带质询的 `GET` 请求——Meta 的 WhatsApp Cloud API 就是其中之一。 除非另有指定,否则服务器路由仅响应 `POST`,因此请声明两种方法:
```ts
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
httpMethods: ['GET', 'POST'],
}
```
质询会在 `event.queryStringParameters` 中到达,返回 `Response` 会在同一请求中将其回传给提供商。 字符串主体会以 `text/plain` 发送,这正是这些提供商所期望的:
```ts
const handler = async (event: RoutePayload) => {
if (event.requestContext.http.method === 'GET') {
return new Response(event.queryStringParameters['hub.challenge']);
}
// …handle the delivered event
};
```
`httpMethods` 会替换默认值,而不是在其基础上添加,因此仅使用 `['GET']` 会使路由拒绝 `POST`。 仅支持 `GET` 和 `POST`。 除非提供商需要第二种方法,否则请将其留空:声明 `GET` 的路由会让任何未经身份验证的调用方执行其解析器,包括会在未提示的情况下发送 `GET` 的爬虫和链接展开器。 对于平台没有对应方法的任何请求,都会返回 `405`,且解析器不会运行。
<Note>
**应用程序必须在其所属工作区中被认领并安装。** 由于 resolver 在**所属工作区**中运行(即拥有应用程序注册的工作区),服务器路由触发器只有在应用程序已被*认领*——也就是说它已有一个所属工作区——**并且**该应用程序**已安装在所属工作区**之后才会生效。 在这两个条件都满足之前,resolver 无处可运行,因此无法分发该路由。 因此,暴露 `serverRouteTriggerSettings` 逻辑函数的应用程序在被认领并安装到其所属工作区之前,不能在 marketplace 中列出。
</Note>
**Resolver 合约。** SDK 的 `LogicFunctionConfig` 类型在编译时强制执行这一点:一旦你设置了 `serverRouteTriggerSettings`,你的处理程序就被限制为返回 `Response`,或 `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }`(或它们之一的 `Promise`)。 在分发(dispatch)路径上,`workspaceId` 必须是已安装目标函数的工作区,否则请求会以 `404` 被拒绝。 如果结果不符合这两种结构中的任何一种——包括标识符不是 UUID 的情况——将会以 `502` 被拒绝。
| 字段 | 类型 | 备注 |
| ---------------------------------------- | ------------ | -------------------------------------- |
| `workspaceId` | `string` | 目标将运行所在的工作区 UUID。 |
| `targetLogicFunctionUniversalIdentifier` | `string` | 要在该工作区中调用的逻辑函数的 `universalIdentifier`。 |
| `payload` | `object`(可选) | 如果设置,将会替换发送到目标的请求正文。 |
<Warning>
**签名验证由你负责——请在 resolver 中进行验证。** 平台不会验证请求签名。 resolver 是执行验证的推荐位置:它最先运行,可以访问 `event.rawBody` 以及你在 `forwardedRequestHeaders` 中列出的请求头,并且只要抛出错误(或返回任意不匹配的 `workspaceId`),就会在调用目标之前停止分发。 如果你反而将验证下推到 target 中,那么 target 必须小心不要丢失 `rawBody` 和请求头——也就是说,resolver 不应返回 `payload`。 始终在产生任何副作用**之前**进行验证,并使用常量时间比较。
</Warning>
对于请求签名,大多数服务商使用 HMAC-SHA256 进行签名;不同之处在于请求头名称、摘要编码方式以及被签名的负载字符串。 例如:
| 提供商 | 要转发的请求头 | 签名字符串 | 摘要 |
| ------------------------- | ------------------------------------------------------ | ---------------------------- | ---------------------------------- |
| Svix(Recall、Resend、Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64(密钥在去掉 `whsec_` 前缀后为 base64) |
| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hex |
| GitHub | `x-hub-signature-256` | `{rawBody}` | hex(前缀为 `sha256=`) |
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex(前缀为 `v0=`) |
上面的 resolver 示例已经展示了 GitHub 的 HMAC-SHA256 流程——请根据你要集成的服务商,调整请求头名称、摘要编码方式以及被签名的负载字符串。
<Note>
当 resolver 返回一个分发对象时,该路由会响应 `202 { queued: true }`,并在工作队列上运行目标函数——调用方不会感知目标函数的延迟、结果或失败(这些都会记录在执行日志中)。 这可以防止发送方的重新投递放大处理变慢的问题,而这正是你在处理 webhook 摄取时所需要的。
当调用方必须在同一个请求中读取响应体(挑战握手、交互式确认)时,请改为从 **resolver** 返回一个 `Response`。 平台会同步回显该响应并跳过队列;其响应头会通过与 HTTP 路由响应相同的允许列表进行过滤。 保持 resolver 足够快速——某些服务商(例如 Slack)会在几秒内超时。 由于 resolver 可以作为公共端点访问,请在边缘(edge)对其进行速率限制保护。
</Note>
#### 数据库事件触发器有效负载
当数据库事件触发器调用你的逻辑函数时,每条被更改的记录都会对应一个 `DatabaseEventPayload`。 该负载将关于源工作区和对象的元数据与记录级事件组合在一起。
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
有效负载包括:
| 属性 | 描述 |
| ------------------------------------------------ | ------------------------------------------------------------ |
| `name` | 事件名称,例如 `person.updated`。 |
| `workspaceId` | 事件发生的工作区。 |
| `objectMetadata` | 已更改对象的元数据。 |
| `recordId` | 已更改记录的 ID。 |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | 当事件由工作区用户触发时的操作者字段。 |
| `properties` | 事件的记录数据,根据操作不同,包含 `before`、`after`、`diff` 和 `updatedFields`。 |
| 事件 | 记录数据 |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
对于软删除,`.deleted` 遵循更新样式的结构,因为记录的 `deletedAt` 字段发生了变化。
对于永久删除,请使用 `.destroyed`。
<Note>
`databaseEventTriggerSettings.updatedFields` 会筛选出哪些更新事件会触发该函数。
`event.properties.updatedFields` 告诉你在当前事件中哪些字段实际发生了变化。
</Note>
创建事件示例:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
更新事件示例:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
仅在 email 更新时触发:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
销毁事件示例:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### 执行上下文
每个处理程序都会收到第二个参数,用于描述运行本身,无论是什么触发了它。 第一个参数会因触发器而具有不同的结构,而这个参数不会:
```ts
import type { LogicFunctionExecutionContext } from 'twenty-sdk/logic-function';
const handler = async (
event: DatabaseEventPayload,
{ workspaceId, workspaceMemberId }: LogicFunctionExecutionContext,
) => {
if (workspaceMemberId === null) {
throw new Error('This function must be triggered by a person');
}
return { reviewedBy: workspaceMemberId, workspaceId };
};
```
| 字段 | 类型 | 描述 |
| ------------------- | ---------------- | --------------------------------------------------------------------- |
| `workspaceId` | `string` | 该运行所属的工作区。 |
| `userWorkspaceId` | `string \| null` | 触发该运行的人员,以其作为此工作区成员的身份。 |
| `workspaceMemberId` | `string \| null` | 同一人员的工作区成员记录,也就是你链接记录时使用的对象。 |
| `retryCount` | `number` | 首次尝试时为 `0`。 请参阅[后台作业](/l/zh/developers/extend/apps/logic/background-jobs)。 |
| `maxRetries` | `number` | 应用程序请求重试次数的上限。 |
当没有人触发运行时,`userWorkspaceId` 和 `workspaceMemberId` 为 `null`:cron 计划、安装钩子和未经身份验证的 Webhook 背后没有人员。 当该人员没有工作区成员记录,或者其记录已被删除时,它们也为 `null`。
<Note>
**上下文会告诉你是谁触发了运行。** 运行可以*执行*什么操作则是下面单独的声明。
</Note>
#### 调用使用谁的访问权限
每个客户端 — `CoreApiClient`、`MetadataApiClient` 和 `RestApiClient` — 都会以触发运行的人员身份执行操作:其角色与你的应用程序角色相交,因此调用所能执行的操作绝不会超过你们任一方的权限。 这是默认行为,意味着人员绝不能使用你的应用超出其自身权限。
当没有人触发运行时,没有可代为执行操作的人员,因此同一客户端会回退到使用你的应用程序自身访问权限:cron 计划、安装钩子和未经身份验证的 Webhook 无需特殊处理。
即使运行背后*有*人员,某些调用也确实需要应用程序自身的访问权限 — 例如读取应用的配置记录,或执行该人员自己无法完成的工作。 为这些情况构建第二个客户端:
```ts
import { CoreApiClient } from 'twenty-client-sdk/core';
const asPerson = new CoreApiClient();
const asApplication = new CoreApiClient({ runAs: 'application' });
const handler = async (event: DatabaseEventPayload) => {
// config records only your app may read
const { slackUserLinks } = await asApplication.query({ ... });
// created as the person, so it is attributed and permission-checked
await asPerson.mutation({ createNote: { ... } });
};
```
在模块作用域中一次性构建两个客户端,之后每个调用位置都会通过所调用的客户端表明其使用的访问权限。 `RestApiClient` 接受相同的选项:
```ts
import { RestApiClient } from 'twenty-client-sdk/rest';
const asApplication = new RestApiClient({ runAs: 'application' });
```
<Note>
**无人触发的运行会以你的应用程序身份执行。** Cron 计划、安装钩子和未经身份验证的 Webhook 背后没有人员,因此默认客户端会回退到使用你的应用程序自身访问权限,并继续正常工作。 仅当你希望在由人员*触发*的运行中使用该访问权限时,才需要 `runAs: 'application'`。
当函数的行为取决于其背后是否有人员时,请检查 `context.workspaceMemberId`,例如用于为记录归属。
</Note>
用于访问你的应用程序自身资源的 SDK 辅助工具始终使用其访问权限,并忽略 `runAs`:包括[键值存储](/l/zh/developers/extend/apps/logic/key-value-store)、[连接](/l/zh/developers/extend/apps/logic/connections)、`runAgent`、`getPublicAssetUrl` 和积分扣费。
#### 将函数公开为 AI 工具或工作流操作
逻辑函数可以在两个入口对外公开,每个入口都有各自的触发器:
* **`toolTriggerSettings`** — 使该函数可被 Twenty 的 AI 功能(chat、MCP、function calling)发现。 使用标准 JSON Schema,LLM 能够原生理解的格式。
* **`workflowActionTriggerSettings`** — 使该函数在可视化工作流构建器中显示为一个步骤。 使用 Twenty 丰富的 `InputSchema`,以便构建器可以呈现合适的字段编辑器、变量选择器和标签。
函数可以选择加入其中一个、另一个,或两者都加入。 它们与 `cronTriggerSettings`、`databaseEventTriggerSettings` 和 `httpRouteTriggerSettings` 并列 — 相同的模式、相同的结构。
<Note>
**与工作流 Code 动作的关系。** 工作流构建器中的内置 **Code** 动作本身就是一个逻辑函数 —— Twenty 会为每个 Code 步骤创建一个逻辑函数,并在行内展示其编辑器。 `workflowActionTriggerSettings` 是将一次性行内代码转换为**可复用**动作的方式:在你的应用中定义一次该函数,它就可以在任意工作流中被选择,而不需要在每个 Code 步骤中复制粘贴。 请参阅用户指南中的[Code 动作](/l/zh/user-guide/workflows/capabilities/workflow-actions#code)以了解终端用户视图。
</Note>
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
return { taskId: result.createTask.id };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
toolTriggerSettings: {},
});
```
关键点:
* 函数可以混用这些入口 — 同时声明 `toolTriggerSettings` 和 `workflowActionTriggerSettings`,即可在 chat 和工作流构建器中同时公开它。
* `toolTriggerSettings.inputSchema` 和 `workflowActionTriggerSettings.inputSchema` 均为可选。 如果省略,清单构建器会根据处理器源代码进行推断(AI 工具使用 JSON Schema,工作流操作使用 Twenty 的 `InputSchema`)。 当你需要更丰富的类型时,可显式提供一个 — 例如,在工作流构建器中使用对 `FieldMetadataType` 友好的字段(如 `CURRENCY` 或 `RELATION`),或提供 AI 智能体可读取的 `description` 字段:
```ts
export default defineLogicFunction({
...,
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
},
},
required: ['companyName'],
},
},
});
```
要只声明**一次**参数并服务于这两种界面,请定义一个单个 JSON Schema(`InputJsonSchema`),并使用来自 `twenty-sdk/logic-function` 的 `jsonSchemaToInputSchema` 将其转换为工作流操作所用。 `toolTriggerSettings.inputSchema` 直接接受 JSON Schema,而 `workflowActionTriggerSettings.inputSchema` 需要的是 Twenty 的 `InputSchema`:
```ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
export default defineLogicFunction({
...,
toolTriggerSettings: { inputSchema },
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
},
});
```
##### 完整的工作流动作示例
`workflowActionTriggerSettings` 接受四个字段:
| 字段 | 目的 |
| -------------- | --------------------------------------------------------------------------- |
| `label` | 在工作流构建器的步骤选择器中为该动作显示的名称。 默认为函数的 `name`。 |
| `icon` | 在该动作旁边显示的图标(一个 `tabler-icons` 名称,例如 `IconBuilding`)。 |
| `inputSchema` | Twenty 提供的功能丰富的 `InputSchema` —— 构建器会将其渲染为可配置字段(带变量选择器)。 可选;若省略,则会从处理函数中推断。 |
| `outputSchema` | 声明处理函数返回的数据结构,这样**后续步骤就可以映射到它的输出字段**。 可选;如果不提供,输出会作为单个不透明值暴露。 |
把上述内容组合在一起 —— 将一个函数暴露为工作流动作,并声明输出,以便后续步骤可以引用 `taskId`:
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
// The keys returned here should match the `outputSchema` properties below.
return { taskId: result.createTask.id, enriched: true };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
outputSchema: [
{
type: 'object',
properties: {
taskId: { type: 'string' },
enriched: { type: 'boolean' },
},
},
],
},
});
```
应用安装完成后,**Enrich Company** 会出现在工作流构建器的动作选择器中。 构建器会将 `companyName` 和 `domain` 渲染为输入字段(每个字段都可以从前面步骤中获取值),而下游步骤可以引用该步骤的 `taskId` 和 `enriched` 输出。
<Note>
**写一个好的 `description`。** AI 智能体会依赖该函数的 `description` 字段来决定何时使用该工具。 明确说明该工具的作用以及应在何时调用。
</Note>
</Accordion>
</AccordionGroup>
<Note>
**运行时辅助工具。** `twenty-sdk/utils` 会重新导出一些小型运行时辅助工具,这样处理程序就不需要直接从 `twenty-shared` 导入。 例如,`isDefined(value)` 对 `null` 和 `undefined` 都会返回 `false` —— 使用它可以安全地收窄可选处理程序输入的类型,因为即使类型标注为 `T | undefined`,在运行时它们仍可能以 `null` 的形式传入:
```ts
import { isDefined } from 'twenty-sdk/utils';
const handler = async (params: { parentMessageId?: string }) => {
if (isDefined(params.parentMessageId)) {
// params.parentMessageId is narrowed to string here
}
};
```
</Note>
<Note>
**安装 hooks**——预安装、后安装和卸载处理程序——共享此运行时,但使用它们自己的 define 函数进行声明,并且不接受触发器设置。 有关 `definePreInstallLogicFunction`、`definePostInstallLogicFunction` 和 `defineUninstallLogicFunction`,请参阅 [Install Hooks](/l/zh/developers/extend/apps/config/install-hooks)。
</Note>
## 创建时间线活动
使用 `createTimelineActivity()` 从逻辑函数发布显式领域事件。 先将事件定义为一种[时间线活动类型](/l/zh/developers/extend/apps/data/timeline-activity-types),然后通过其稳定的通用标识符来指定该类型和对象:
```ts
import { createTimelineActivity } from 'twenty-sdk/logic-function';
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
happensAt: new Date().toISOString(),
properties: {
deliveryProvider: 'postal-service',
},
});
```
Twenty 会将通用标识符解析为特定于安装的元数据 ID,验证时间线活动类型属于调用应用,并在新活动上对其展示元数据创建快照。
必需的输入为 `timelineActivityTypeUniversalIdentifier`、`targetObjectUniversalIdentifier` 和 `targetRecordId`。 您还可以提供 `happensAt`、`properties` 和 `workspaceMemberId`。 `happensAt` 控制事件在时间线中的位置和显示时间;默认值为创建时间。
要将另一条记录与事件关联,请同时提供 `linkedRecordId` 和 `linkedObjectMetadataUniversalIdentifier`。 您还可以提供 `linkedRecordCachedName` 作为历史显示回退值:
```ts
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
linkedObjectMetadataUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
linkedRecordId: recipientId,
linkedRecordCachedName: recipientName,
});
```
逻辑函数的角色需要对标准 `timelineActivity` 对象具有写入权限。 保持显式事件类型不绑定 `action`;绑定操作的类型已接收自动审计事件,否则会产生重复行。
## 类型化 API 客户端(`twenty-client-sdk`)
`twenty-client-sdk` 包提供了两个类型化的 GraphQL 客户端,供你的逻辑函数和前端组件与 Twenty API 交互。
| 客户端 | 导入 | 端点 | 是否生成? |
| ------------------- | ---------------------------- | ------------------------ | --------- |
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql`——工作区数据(记录、对象) | 是,在开发/构建时 |
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata`——工作区配置、文件上传 | 否,已预构建提供 |
<AccordionGroup>
<Accordion title="CoreApiClient" description="查询和变更工作区数据(记录、对象)">
`CoreApiClient` 是用于查询和变更工作区数据的主要客户端。 它会在执行 `yarn twenty dev` 或 `yarn twenty dev:build` 时**根据你的工作区架构生成**,因此具有完整的类型定义以匹配你的对象和字段。
```ts
import { CoreApiClient } from 'twenty-client-sdk/core';
const client = new CoreApiClient();
// Query records
const { companies } = await client.query({
companies: {
edges: {
node: {
id: true,
name: true,
domainName: {
primaryLinkLabel: true,
primaryLinkUrl: true,
},
},
},
},
});
// Create a record
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Acme Corp',
},
},
id: true,
name: true,
},
});
```
该客户端使用选择集语法:传入 `true` 以包含某字段,使用 `__args` 传递参数,并通过嵌套对象表示关系。 你将基于工作区架构获得完整的自动补全和类型检查。
<Note>
**CoreApiClient 在开发/构建时生成。** 如果在未先运行 `yarn twenty dev` 或 `yarn twenty dev:build` 的情况下尝试使用它,将会抛出错误。 该生成过程是自动完成的——CLI 会自省你的工作区 GraphQL 架构,并使用 `@genql/cli` 生成类型化客户端。
</Note>
#### 使用 CoreSchema 进行类型标注
`CoreSchema` 提供与工作区对象相匹配的 TypeScript 类型,可用于为组件状态或函数参数进行类型标注:
```ts
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { useState } from 'react';
const [company, setCompany] = useState<
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
>(undefined);
const client = new CoreApiClient();
const result = await client.query({
company: {
__args: { filter: { position: { eq: 1 } } },
id: true,
name: true,
},
});
setCompany(result.company);
```
</Accordion>
<Accordion title="MetadataApiClient" description="工作区配置、应用和文件上传">
`MetadataApiClient` 随 SDK 一并提供,已预构建(无需生成)。 它会查询 `/metadata` 端点以获取工作区配置、应用和文件上传。 它接受与 `CoreApiClient` 相同的 `runAs` 选项 — 请参阅[调用使用谁的访问权限](#whose-access-a-call-uses)。
```ts
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const metadataClient = new MetadataApiClient();
// List first 10 objects in the workspace
const { objects } = await metadataClient.query({
objects: {
edges: {
node: {
id: true,
nameSingular: true,
namePlural: true,
labelSingular: true,
isCustom: true,
},
},
__args: {
filter: {},
paging: { first: 10 },
},
},
});
```
#### 上传文件
`MetadataApiClient` 包含一个 `uploadFile` 方法,用于将文件附加到文件类型字段:
```ts
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier
);
console.log(uploadedFile);
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
```
| 参数 | 类型 | 描述 |
| ---------------------------------- | -------- | -------------------------------------------- |
| `fileBuffer` | `Buffer` | 原始文件内容 |
| `filename` | `string` | 文件名称(用于存储和显示) |
| `contentType` | `string` | MIME 类型(如果省略,默认为 `application/octet-stream`) |
| `fieldMetadataUniversalIdentifier` | `string` | 你的对象上文件类型字段的 `universalIdentifier` |
关键点:
* 使用字段的 `universalIdentifier`(而不是其工作区特定的 ID),因此你的上传代码可在安装了你的应用的任何工作区中运行。
* 返回的 `url` 是一个签名 URL,你可以用它来访问已上传的文件。
</Accordion>
</AccordionGroup>
<Note>
当你的代码在 Twenty 上运行(逻辑函数或前端组件)时,平台会以环境变量的形式注入凭据:
* `TWENTY_API_URL`——Twenty API 的基础 URL
* `TWENTY_APP_ACCESS_TOKEN` — 用于默认访问权限的短期密钥:当运行背后有人员时,使用该人员角色与你的应用程序角色的交集;当没有人员时,使用你的应用程序自身角色。 在逻辑函数中,该人员是触发运行的人;在前端组件中,则是正在查看页面的人。
* `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — 仅限定于你的应用程序自身角色的短期密钥。 仅用于逻辑函数,始终会在其中注入,并由 `runAs: 'application'` 使用。
你**不**需要将这些传递给客户端 — 它们会自动从 `process.env` 读取,而[调用使用谁的访问权限](#whose-access-a-call-uses)介绍了如何在它们之间进行选择。 你的应用程序自身权限由通过 `defineApplicationRole()` 声明的角色(或在 `application-config.ts` 中通过 `defaultRoleUniversalIdentifier` 引用的角色)决定;以人员身份执行的运行绝不能超出该角色或该人员角色的权限。
</Note>