首页
/ Twenty App 数据模型开发指南:对象、字段、关系与角色权限建模(基于 twenty-codex-plugin 开发参考)

Twenty App 数据模型开发指南:对象、字段、关系与角色权限建模(基于 twenty-codex-plugin 开发参考)

2026-09-07 14:03:08作者:齐添朝

本指南以 data-model.md 为核心,系统讲解在 Twenty 应用中如何定义对象(Object)、字段(Field)、关系(Relation)、角色(Role)与权限,并给出"建模即可用"的完整落地模式。读完你将掌握 defineObject / defineField / defineRole 的规范写法、Select 字段的取值约定、最小可用对象所需的 5 类注册文件,以及如何用 yarn twenty CLI 校验并同步数据模型。

数据模型在 Twenty 应用中的作用域

在 Twenty 的应用开发模型里,对象和字段定义的是用户将要创建、查看、搜索和自动化的记录结构,是视图(view)、导航(navigation)、记录页布局(page layout)、工作流(workflow)与逻辑函数(logic function)共同作用的基础。数据模型设计的好坏,直接决定后续过滤、视图与自动化功能是否顺滑。

本参考面向的场景是:你在开发一个 Twenty App(例如 rich-app 夹具hello-world 示例),需要为某个用户工作流新增对象、字段和权限。配套的周边主题可参考同一开发参考目录下的 app-structure.md(文件组织与实体创建)、layout.md(视图/导航/页面布局)、logic.md(逻辑函数与副作用)、tests.md(测试约定)等文档。

对象与字段:先建模工作流,再加最小字段集

设计对象与字段时,首要原则是先梳理用户的工作流,再添加让该工作流可用所必需的最小字段集。多余的字段不会让应用更强,只会抬高配置与维护成本。具体准则如下:

  • 优先使用脚手架生成的应用实体模式:新增对象或字段时优先执行 yarn twenty dev:add,从生成模板中获得一致的配置形状(详见 app-structure.md 对交互式生成的描述)。
  • 使用清晰、映射用户语言的对象名与字段名:例如对象 postCard(label Post card)比抽象的内部命名更容易被业务方理解。
  • 仅在用户需要跨记录导航或跨记录报表时才添加关系:关系不是数据建模的"默认动作",而是有明确需求才引入的结构。
  • 避免复制核心 Twenty 对象上已有的数据:例如时间线动态(timelineActivity)这类标准对象可直接复用,不应在你的应用对象里重复造一份。
  • 保持字段类型足够具体,以支撑过滤、视图和自动化。从 rich-app 的真实对象可以看到,一个"收件人"被建模为 EMAILS(邮件)、ADDRESS(地址)等专用类型,而不是塞进一个宽泛的文本字段。

关于字段类型枚举本身:twenty-sdk/define 导出的 FieldType 实际是 twenty-shared/typesFieldMetadataType 的再导出(见 field-type.ts)。仓库应用中实际出现过的类型包括 TEXTSELECTFULL_NAMEADDRESSEMAILSDATE_TIMERELATION 等,选取时应与字段的语义一一对应。

让对象"建模即可用":可用的对象模式

当用户要求新增一个对象、且没有明确限定只做 schema 时,不应只交付一个孤立的 defineObject,而应把数据模型工作与最小布局/导航表面配套完成,让对象真正进入应用可见、可操作:

注册文件(相对 src/ 作用
objects/<name>.ts 定义对象及其字段(数据模型的载体)
views/all-<plural>.ts 对象的默认表格视图(记录列表入口)
navigation-menu-items/<name>.ts 侧边导航中的对象入口
page-layouts/<name>-record-page-layout.ts 记录页布局(展示记录当前状态与下一步动作)
views/<name>-record-page-fields.ts 记录页上的字段小部件视图

其中视图、导航、页面布局的具体细节,原文档明确指引到 layout.md 处理;需要承载整页自定义 UI 时则参考 standalone-pages.md

对象颜色属于导航项,而不是对象本身

defineObject() 上只支持 icon不支持定义对象颜色。颜色的正确归宿是对象导航项。原因在于导航项才是用户"看到颜色"的表面,把颜色放在对象定义上会造成语义错位。导航菜单项示例:

import {
  defineNavigationMenuItem,
  NavigationMenuItemType,
} from 'twenty-sdk/define';

export default defineNavigationMenuItem({
  universalIdentifier: '<uuid>',
  name: '<name>',
  icon: '<IconName>',
  color: '<color>',
  position: 0,
  type: NavigationMenuItemType.OBJECT,
  targetObjectUniversalIdentifier: '<object-uuid>',
});

对照仓库实现,post-cards 导航项 与文档示例完全一致:通过 type: NavigationMenuItemType.OBJECT 声明导航项类型,用 targetObjectUniversalIdentifier 指向对象(此处直接复用从 post-card.object.ts 导出的 POST_CARD_UNIVERSAL_IDENTIFIER 常量)。这暴露了一个关键实践——对象的 universalIdentifier 应导出为具名常量,供视图、导航、字段、角色等文件以 import 方式引用,避免多个文件手写 UUID 导致不一致。

最小对象示例:一份可复制的 defineObject

下面是原文档给出的最小对象模板。一个对象至少需要一个"标识字段"(labelIdentifierFieldMetadataUniversalIdentifier 指向的字段),它负责在列表、引用与搜索中代表该记录:

import { defineObject, FieldType } from 'twenty-sdk/define';

export const OBJECT_UNIVERSAL_IDENTIFIER = '<uuid>';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER = '<uuid>';

export default defineObject({
  universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
  nameSingular: '<name>',
  namePlural: '<names>',
  labelSingular: '<Name>',
  labelPlural: '<Names>',
  icon: '<IconName>',
  labelIdentifierFieldMetadataUniversalIdentifier:
    NAME_FIELD_UNIVERSAL_IDENTIFIER,
  fields: [
    {
      universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
      type: FieldType.TEXT,
      name: 'name',
      label: 'Name',
      icon: 'IconAbc',
    },
  ],
});

字段级别的细节可以从仓库的富对象中验证。以 post-card.object.ts 为例:

  • 每个字段拥有独立的 universalIdentifier(如 CONTENT_FIELD_UNIVERSAL_IDENTIFIERSTATUS_FIELD_UNIVERSAL_IDENTIFIER),并作为具名常量导出;对象级与字段级 ID 彼此独立、互不混用。
  • 复合类型直接内嵌建模:如 FieldType.FULL_NAMErecipientNameFieldType.ADDRESSrecipientAddress,无需再展开成多个子字段。
  • 可空/可选字段显式声明:isNullable: true 配合 defaultValue: null(例如 deliveredAtDATE_TIME 字段)。
  • 可选 description 为对象与字段补充业务语义,便于界面提示与后续维护。

对象级同样支持 description(如 rich-app 中 'A person or organization that receives post cards'),它并不改变行为,但能显著提升团队协作与 AI 辅助开发时的可理解性。

Select 字段:取值必须为大写蛇形

对于单选(SELECT)与多选字段,选项的 value 字符串必须是 UPPER_SNAKE_CASE(如 PLANNEDIN_BUILD)。label 是展示给用户的文案(如 PlannedIn build),value 是存储与逻辑引用的稳定键,二者必须分开。示例:

{
  type: FieldType.SELECT,
  name: 'status',
  label: 'Status',
  defaultValue: "'PLANNED'",
  options: [
    { position: 0, label: 'Planned', value: 'PLANNED', color: 'sky' },
    { position: 1, label: 'In build', value: 'IN_BUILD', color: 'orange' },
  ],
}

最容易踩的坑是默认值写法:Select 字段的 defaultValue 必须是带引号的字符串表达式,即 "'PLANNED'"(单引号是表达式语法的一部分),而不是裸字符串 'planned'"PLANNED"defaultValue 本质上是一个取值表达式,因此要求值与选项 value 完全一致且为大写蛇形。

仓库实现给出了更完整的生产级写法。post-card.object.ts 用 TypeScript enum 集中定义状态常量,再以模板字符串注入默认值,杜绝手写字符串导致的拼写漂移:

enum PostCardStatus {
  DRAFT = 'DRAFT',
  SENT = 'SENT',
  DELIVERED = 'DELIVERED',
}

// ...
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
  { value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' },
  { value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' },
  // ...
],

其中 position 决定选项顺序,color 取自颜色语义值(gray/orange/green/red 等)。选项的 id 可省略——从源码注释("No id — exercises addMissingFieldOptionIds")可以推断,省略 id 的选项会由对象同步逻辑补齐缺失的选项 ID。状态机语义靠不同颜色区分(如 DELIVERED 用绿、LOST 用红),这样无需编码即可让用户扫一眼看懂记录状态。

关系字段:仅在需要跨记录导航与汇总时添加

原文档把关系定位为"需求驱动"而非"默认行为":只有当用户需要跨记录导航(从一张明信片看到它的收件人)或做跨对象报表时,才添加关系。判断标准是:这段数据能否在某个单一对象内完整表达?如果能,就不要引入第二个对象与关系。

当确需关系时,rich-app 给出了两种关系的落地路径:

  • 普通对象上的关系字段通过 defineField + FieldType.RELATION 表达;更多关系方向与命名可参考 recipient.object.ts 以及对象间的 field 文件。
  • 多对多(含连接对象) 需要声明 RelationType.ONE_TO_MANY 并通过 junctionTargetFieldUniversalIdentifier 指定连接对象另一侧字段。见 post-card-recipients-on-post-card.field.ts
export default defineField({
  universalIdentifier: POST_CARD_RECIPIENTS_ON_POST_CARD_ID,
  objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
  type: FieldType.RELATION,
  name: 'postCardRecipients',
  label: 'Post Card Recipients',
  relationTargetObjectMetadataUniversalIdentifier:
    POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
  relationTargetFieldMetadataUniversalIdentifier:
    POST_CARD_ON_POST_CARD_RECIPIENT_ID,
  universalSettings: {
    relationType: RelationType.ONE_TO_MANY,
    junctionTargetFieldUniversalIdentifier: RECIPIENT_ON_POST_CARD_RECIPIENT_ID,
  },
});

注意关系两侧(父侧 postCardRecipients 与子侧反向字段)各自拥有独立的 universalIdentifier 常量,且互相通过 relationTargetFieldMetadataUniversalIdentifier 配对。这也是为什么 schema-only 改动极少见——一旦引入关系,就需要同步设计视图与记录页,否则用户无法实际"跨越"对象。

角色与权限:按职责而非实现便利建模

角色的划分应当匹配"操作职责"(operational responsibility),而不是"实现上的便利"。也就是说,角色反映的是"谁在真实业务中负责做什么",而不是"这段代码刚好需要什么权限"。添加权限时遵循三条原则:

  • 授予最小的有用范围(smallest useful scope):能只读就不给写,能限定到对象就不放开到全量。
  • 把敏感对象和字段挡在宽泛角色之外:例如全员角色不应默认可读高敏字段的值。
  • 授予写权限前,检查应用是否通过逻辑函数引入了副作用:逻辑函数(logic function)会在特定时机执行代码,若某角色可写某对象、该对象上又挂了有副作用的逻辑函数,那么"写权限"实际会放大成"触发副作用"的能力。这一点要结合 logic.md 一起评估。

仓库中的角色实现展示了权限的三种粒度。系统级"万能"角色见 root.role.ts:通过 canReadAllObjectRecordscanUpdateAllSettingscanBeAssignedToUsers 等开关整体放权;而真正遵循最小权限原则的示例是 default-function.role.ts

export default defineRole({
  universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
  label: 'Default function role',
  description: 'Default role for function Twenty client',
  canReadAllObjectRecords: false,
  canUpdateAllObjectRecords: false,
  // 不可赋给用户/Agent/API Key,仅供逻辑函数内部客户端使用
  canBeAssignedToAgents: false,
  canBeAssignedToUsers: false,
  canBeAssignedToApiKeys: false,
  objectPermissions: [
    {
      objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
      canReadObjectRecords: true,
      canUpdateObjectRecords: true,
      canSoftDeleteObjectRecords: false,
      canDestroyObjectRecords: false,
    },
    {
      // 复用标准对象,而非复制其数据
      objectUniversalIdentifier:
        STANDARD_OBJECTS.timelineActivity.universalIdentifier,
      canReadObjectRecords: true,
      canUpdateObjectRecords: true,
    },
  ],
  fieldPermissions: [
    {
      objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
      fieldUniversalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
      canReadFieldValue: false,
      canUpdateFieldValue: false,
    },
  ],
  permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});

这份源码把原文档的三条原则落实为可执行结构:

  1. 对象级权限 objectPermissionsobjectUniversalIdentifier 逐对象指定 canReadObjectRecords / canUpdateObjectRecords / canSoftDeleteObjectRecords / canDestroyObjectRecords,代替全局放开。
  2. 字段级权限 fieldPermissions 精确到"某对象的某字段"上控制 canReadFieldValue / canUpdateFieldValue —— 这正是"敏感字段不进入宽泛角色"的实现手段。
  3. 角色可赋给谁canBeAssignedToAgents / canBeAssignedToUsers / canBeAssignedToApiKeys 控制,另有 SystemPermissionFlag 级别的功能开关。将"仅供逻辑函数客户端使用"的角色设为三者皆不可赋,可从源头防止权限被误扩散到交互用户。

顺带一提,STANDARD_OBJECTS 来自 twenty-shared/metadata,它印证了"避免重复核心 Twenty 对象数据"的准则——标准对象(如 timelineActivity)可以直接以权限对象身份参与你的角色配置。

验证:改完数据模型后如何确认可用

数据模型改动完成后,需要运行应用,验证对象、字段和角色出现在用户将要管理与使用它们的地方:

  • 对象是否出现在导航与对象列表中,字段是否在表格视图、记录页布局与字段小部件中可见;
  • 角色是否能在预期的位置被分配给目标主体,权限是否如配置般生效;
  • 不要停留在"代码能编译"层面——用户可感知的路径(导航 → 视图 → 记录详情)必须真实可达。视图与记录页路径的验证细则见 layout.md

在进入运行验证之前,一次性的静态校验是低成本保障。按 app-structure.md 的约定,全部编辑完成后只跑一轮(不要在每步编辑后反复执行):

yarn twenty dev:typecheck   # 校验生成的类型
yarn lint                   # 本地 lint 规则
yarn twenty apply           # 构建应用并把实体定义推送到活动 remote

yarn twenty apply 会构建应用并把实体定义同步到活动 remote;任一对象、字段或角色定义非法,同步都会报告错误。若同步或 remote 出现异常,转由管理指南处理,见 cli-and-sync.md

小结

Twenty 应用的数据模型不是孤立的 schema 文件,而是一套"对象 → 字段 → 视图/导航/页面布局 → 角色权限"的完整注册体系:

  • 先建模用户工作流,只添加让工作流可用的最小字段集,避免复制核心对象已有数据;
  • 新增对象默认做到"建模即可用",补齐表格视图、导航项、记录页布局与字段小部件这 5 类文件;对象颜色放在导航项而非对象定义上;
  • Select 选项 value 统一为大写蛇形,defaultValue 必须写成 "'PLANNED'" 形式的带引号表达式;
  • 关系仅在需要跨记录导航或汇总时引入,多对多通过连接对象与 RelationType.ONE_TO_MANY 配对实现;
  • 角色匹配业务职责而非实现便利,优先使用 objectPermissions + fieldPermissions 的最小作用域,并在授予写权限前评估逻辑函数副作用。

写完定义后运行应用、并结合 dev:typecheck / lint / apply 完成一轮验证,数据模型即可安全地进入视图布局(layout.md)、逻辑函数(logic.md)与测试(tests.md)等后续环节。

登录后查看全文
热门项目推荐
相关项目推荐