twenty-partners 架构深度解析:在 Twenty CRM 之上构建合作伙伴计划运营系统
本仓库的 packages/twenty-apps/internal/twenty-partners 是一个真实运行的内部 Twenty 应用,它把 Twenty CRM 变成 Twenty 合作伙伴计划的“操作系统”:录入符合资格的商机(partner-eligible deal)、将其匹配给经过审核的市场合作伙伴、并端到端跟踪整条匹配流水线。本文以该应用的 CLAUDE.md 与由其引入的 AGENTS.md 为主体骨架,结合源码目录、常量与测试文件,系统讲解其业务模型、匹配状态机、自动匹配机制、纵向切片架构与依赖规则,帮助读者掌握“如何用 Twenty SDK 组织一个规模化内部业务应用”。
从一份只有一行内容的 CLAUDE.md 说起
twenty-partners/CLAUDE.md 的全文只有一行:
@AGENTS.md
这是本仓库典型的“代理指令文件”组织方式:仓库级的 CLAUDE.md 约定全体开发者通用的规范(kebab-case 文件命名、具名导出、禁用 any、用 types 而非 interface、简短 // 注释等),而应用级的 AGENTS.md 只补充 partners 特有的架构约束。因此要真正理解这个应用,需要以 AGENTS.md 与 README.md 为入口展开。
这个应用解决什么问题
按 README 的定位,twenty-partners 的目标是:把 CRM 变成 Twenty 合作伙伴计划的运营系统,具体承担三件事——
- 录入(intake):接收符合条件的商机/客户需求;
- 匹配(matching):把商机与经过审核(vetted)的市场合作伙伴配对;
- 跟踪(tracking):端到端跟踪从匹配到交付再到赢单/流失的完整管线。
整个应用与 twenty-apps 下的示例应用一样,构建在 Twenty SDK 之上(应用级配置文件见 src/application-config.ts),数据模型、业务逻辑、权限、界面导航都以“声明 + 逻辑函数”的形式声明在 SDK 中,而非传统的手写 CRUD。
核心数据模型:Partner 对象与 Opportunity 扩展
README 概括了应用的两类核心模型:
Partner(合作伙伴)对象承载合作伙伴画像的关键字段:
slug(唯一标识字符串)status(合作状态)availability(可承接新单的可用性)- served geographies(服务的地理区域)
- languages spoken(使用的语言)
- deployment expertise(部署专长)
- Calendly 链接(用于预约/转介绍)
- last-match timestamp(最近一次被匹配的时间戳,自动匹配的依据之一)
在源码结构中,这一对象的声明位于 src/modules/partner/objects、字段声明位于 src/modules/partner/fields,而字段的 UUID 常量集中在 partner-field-universal-identifiers.ts,选项值常量集中在 partner-option-values.constant.ts。此外 partner-country-view-groups.ts 这类文件用于按国家/地区分组的视图。
Opportunity(商机)扩展字段则描述一笔潜在合作的状态推进:
- 匹配状态(match status)
- 设计文档状态(design-doc status,例如
Listed商机在/twenty-lead-brief中会写入 Design Doc URL) - 初次介绍(intro)与再次联系(relance)的时间戳
- 与已匹配 Partner 的关系字段(relation)
关于商机所属的销售阶段,可在 opportunity-stage-options.ts 中看到完整定义:在 Twenty CRM 标准的 NEW → SCREENING → MEETING → PROPOSAL → CUSTOMER 五段销售漏斗之上,追加了归档用的 DONE(绿)与 DEAD(灰)两档;每个选项携带固定的 UUID、label、position 与颜色,并导出类型 OpportunityStageValue。文件中还导出了阶段字段的 universal identifier 20202020-6f76-477d-8551-28cd65b2b4b9,这类以 20202020- 开头的 UUID 正是 Twenty 生态中跨模块引用对象的“通用标识符”约定。
匹配状态流水线:matchStatus 生命周期
README 给出了商机生命周期中 matchStatus 的状态机,这是理解应用“怎么走流程”的核心:
| 状态 | 含义 |
|---|---|
TO_BE_MATCHED |
默认态 —— 商机已录入,等待分配 |
MANUAL_MATCH |
需要人工挑选合作伙伴 |
AUTO_MATCH |
触发自动分配合作伙伴 |
MATCHED |
已分配合作伙伴 |
INTRODUCED_TO_A_PARTNER |
已发送客户介绍 |
WORKING_WITH_A_PARTNER |
合作已启动,处于推进中 |
IMPLEMENTING |
正在实施交付 |
WON |
商机赢单 |
RECONNECT_LATER |
暂停 —— 未来再联系 |
LOST |
商机流失 |
可以看到它是一条“录入 → 分配 → 介绍 → 协作 → 赢单/流失”的推进链,同时也保留 RECONNECT_LATER 这类非线性的旁路状态,便于运营把暂时谈不拢的机会搁置而非丢失。
自动匹配与人工兜底
README 对自动匹配的描述是:当商机被置为 auto-match 时,系统会分配**闲置时间最长(longest-idle)且当前可用(available)**的合作伙伴,并把商机标记为 MATCHED;如果当时没有任何可用合作伙伴,则商机转入人工匹配流程(MANUAL_MATCH),并附带一条说明性备注解释原因。
与之对应的实现散落在 src/modules/opportunity/matching 域内,从文件名可以清晰还原其触发点与处理链:
on-opportunity-listed.logic-function.ts/on-opportunity-listed.test.ts——商机被“上架(Listed)”时的响应;on-opportunity-partner-assigned.logic-function.ts与其.integration-test.ts——合作伙伴被分配后的响应;on-opportunity-intro-sent.logic-function.ts/on-opportunity-intro-sent.test.ts——客户介绍已发送;on-opportunity-partner-won.logic-function.ts/on-opportunity-partner-won.test.ts——赢单处理;services/下的业务逻辑(如notify-listed-brief.service.ts、propagate-partner-user.service.ts、sync-application-outcomes.service.ts)承担真正可单测的逻辑。
即:UI 或自动化触发的是一个薄薄的 *.logic-function.ts,真正干活的是 service,这正是下文“依赖规则”所要求的形态。
视图:把流水线“看到”侧边栏
为了支撑运营,应用提供多组视图并在侧边栏暴露入口:
- 等待匹配队列(waiting-for-match queue):
TO_BE_MATCHED/MANUAL_MATCH的商机待办; - 匹配漏斗总览(matching funnel overview):按匹配状态分组的漏斗统计;
- 合作伙伴索引(partner index):全部 Partner 的可检索清单;
- 已匹配商机日志(log of matched deals):历史匹配记录。
这些视图的声明集中在 src/modules/opportunity/views、src/modules/partner/directory,导航入口则在各模块的 navigation-menu-items/ 与 page-layouts/ 中声明。
目录结构:纵向切片(vertical-slice)模块化
AGENTS.md 记录了本应用最重要的组织决定:所有领域代码都下沉到 src/modules/ 下,一个领域一个文件夹;根目录 src/ 只保留六类不可移动的东西:
src/
application-config.ts # 应用 + 应用级变量 —— 不变
default-role.ts # 不变
constants/universal-identifiers.ts # ⚠️ 禁止移动(见“不变量”)
scripts/ # ⚠️ 禁止移动 —— seed/purge/rls/slugify 等脚本
roles/ # 应用级 RLS —— 保持在根(横跨所有对象)
skills/ # 打包随附的 Claude skills —— 不变
workflows/ # 打包随附的工作流 —— 不变
__tests__/helpers/ # 共享测试设施(client、fixtures、cleanup)
modules/
shared/ # 跨领域:secret-guard(http/)、find-or-create、
# paginate、utils、front-components、跨域导航目录
<domain>/ # partner · application · opportunity · …
<name>.logic-function.ts # 薄的 SDK 入口 —— 位于领域/功能根,不放子目录
objects/ fields/ view-fields/ views/ navigation-menu-items/ page-layouts/
constants/ # 值列表 + 声明 UUID 映射(不是 types)
services/ # 业务逻辑(可测试)
graphql/{queries,mutations}/ # 唯一允许存放裸 query/mutation 的地方
connector/ # 出站第三方 API,每个 API 一个目录
mappers/ utils/ types/ front-components/
对照实际代码目录,当前 src/modules/ 下确实只有四个领域目录:partner/、opportunity/、application/ 与 shared/。其中:
partner/再按特性拆分为self-service(合作伙伴自助维护资料)、marketplace(市场侧)、application-intake(申请录入与审核)、directory(合作伙伴名录)、onboarding(入驻)等;opportunity/拆分为intake(商机录入)、matching(匹配处理)、how-to-process(操作手册页面)、constants、fields、views、page-layouts、navigation-menu-items等;application/这类单一功能的小领域则可以直接保持<domain>/<primitive>/的扁平形态,不强制加<feature>/层。
设计意图很明确:一个领域是“自包含切片”,它的声明(objects/fields/views…)和它的逻辑(logic-functions → services → graphql/connector)放在一起。新增功能等于新增一个文件夹,而不是把文件撒进十个扁平目录。
依赖规则:唯一需要严格遵守的架构红线
AGENTS.md 里最有分量的一句话是“the dependency rule(the one that matters —— never import upward)”,依赖方向被严格限定为单向:
logic-functions → services → { graphql, connector, mappers } → shared · utils · types
各层职责与禁则是:
- 入口层(
*.logic-function.ts,位于领域/功能根):只做 SDK 入口。解析入参 → 调用一个 service → 返回。不允许承载业务逻辑、不允许直接写裸 GraphQL、不允许发外部 HTTP。如果某个 logic-function 超过约 40 行,说明它越俎代庖干了 service 的活,应当抽取。 services/:全部业务逻辑所在。要求可测试、与 SDK/传输层解耦,并且不得 import 任何 logic-function。graphql/:唯一允许裸 query/mutation 存在的目录,且要求使用具名、带类型的操作,禁止在业务代码里内联client.query(...)。connector/:唯一允许调用出站第三方 API 的地方,每个第三方 API 一个目录(<name>.connector.ts/config.ts/types.ts)。需要特别辨析:入站 webhook 不属于 connector——应用接收的 webhook 只是受modules/shared中 secret-guard 工具保护的普通 logic-function 入口。AGENTS.md 明确说明,当前真正的出站 connector 只有 Discord;TFT、client-brief、partner-application 都属于入站。- 禁止领域之间横向 import 逻辑:跨域共享一律走
modules/shared/。(唯一例外:关系字段天然引用对方对象的 ID 常量,这属于 schema 引用而非逻辑依赖,跨模块 import 是允许的。)
从代码看,opportunity/intake 下的 import-opportunity-from-tft.logic-function.ts 与 submit-client-brief.logic-function.ts 即为典型的薄入口;围绕它们各自配了单元测试(.test.ts)与集成测试(.integration-test.ts),入口背后是 graphql/、mappers/、services/ 三个支撑目录。
命名与测试规范
- 命名:
<name>.<primitive>.ts,例如submit-application.logic-function.ts、resolve-candidacy.service.ts、partner.object.ts。所有文件一律 kebab-case,即使 React 组件也不允许 PascalCase(如profile-picture-upload.tsx)。 - 测试与被测代码同目录放置,并按“种类”拆分:
<name>.test.ts= 单元测试(mock client、快速、无需基础设施);<name>.integration-test.ts= 真实 workspace 的集成测试(需要 live server + 全局 setup,由 tsconfig 排除出普通编译)。
- 二者的区分通过 vitest 的 project 机制选择(
--project unit),而不是按文件数量硬性规定;规范建议用一个vitest.config.ts挂两个 project。禁止把原本 mock 的单元测试改名成.integration-test.ts。 - 共享测试设施集中在 src/tests/helpers。
例如 submit-client-brief.test.ts 与 submit-client-brief.integration-test.ts 同处一个目录,分别覆盖 mock 与真实 workspace 两种场景;matching 域内也能看到 on-opportunity-intro-sent.test.ts、on-opportunity-partner-assigned.integration-test.ts 这类按种类命名的对。
移动代码时必须守住的“不变量”
由于 Twenty SDK 按 universalIdentifier(而非文件路径)跟踪对象,重构/迁移时以下约束一旦被破坏会造成对象重复注册或失联:
- 一组
universalIdentifierUUID 在移动过程中必须逐字节保持一致——纯git mv是安全的,改了或删了 UUID 就等于重新注册或孤儿化一个对象。 - 禁止移动 src/constants/universal-identifiers.ts——env-toolkit 按路径逐 bundle 重写该文件。
- 禁止移动
src/scripts/*——package.json按路径调用它们。 - 所有 import 该文件的人必须继续指向根文件,禁止把其中的 UUID 导出搬进某个模块。
- 禁止跨领域用悬空 ID import 拆分关系——成对的关系字段需要互相导出/导入对方的 field-ID 常量,要么把关系双方放在同一个领域,要么把共享 ID 提升到
shared/。 - 迁移时每个 commit 只搬一个领域(只允许
git mv,import 修正必须在同一 commit 内完成)。
这些不变量解释了为什么根目录布局“锁死”的部分那么少但那么关键。
操作手册(playbook)与行为“锁步”维护
AGENTS.md 反复强调一个工程实践:凡是写到页面上的操作手册,必须与真实行为保持锁步(lockstep),改行为时必须同一次变更里更新手册,不允许在 twenty.com 或笔记里另存一份副本。
申请审核手册(Application review playbook)
位于 src/modules/partner/application-intake/(front-component + page layout + nav item)。当以下任一内容变更时,须在同一变更里更新该手册:
- 落到 Partner 的官网表单字段;
- Partner Applications 视图(filter、columns、sort);
- 用作审核结论的
validationStage取值; - 审核人员需要知晓的录入副作用——创建时发 Discord 通知;
VALIDATED时发欢迎邮件(邮件由WELCOME_EMAIL_WORKFLOW_URL指向的工作流驱动); - 市场排名加成规则,即 packages/twenty-website/src/partners-marketplace/completeness-score.ts 中关于 case study、cover、introduction、service、picture、calendar、rate、category、ghost rule 的加分项。
页面上的“事实”一律来自视图和录入逻辑所用的同一组常量,避免出现过期步骤。
商机处理手册(Opportunity process playbooks)
How to process(面向匹配管理员)与 How to apply(面向合作伙伴工作区)位于 src/modules/opportunity/how-to-process/。同样要求与真实操作行为锁步,变更以下任一环节时须同步更新手册及其文案测试:
- 录入(Intake):marketplace brief 在“已上架”后发 Discord;Twenty Internal 不公开列出;
/twenty-lead-brief写入 Design Doc URL; - 上架(Listing):
Listed开关翻转 → Discord 通知、每日摘要、Open Briefs 成员资格; - 介绍(Intro):设置
Intro Sent At会关闭 Listed;邀请制申请(Invited applications); - 赢单/关闭(Win):设置 Partner → Won/Declined;Backup(备选合作伙伴)保留;取消分配会把 Won 重新打开为 Applied;Deals 阶段按
New → Screening → Meeting → Proposal → Customer推进; - 申请(Apply):Open Briefs 仅对已上架商机开放申请;有最低 pitch 要求;同一商机只能投一份申请;说明各状态含义;
- 打包技能(Bundled skills):
twenty-lead-brief、twenty-partner-shortlist、twenty-partner-intro三者的本地运行方式与预期输出(可在src/skills下找到它们的声明); - 通过 MCP 匹配:操作员可以让 Agent 直接写 CRM 变更来完成匹配。
页面用“操作员语言”书写;How to process 允许出现技能名、斜杠触发词与打包技能的仓库路径;How to apply 必须包含 Open Briefs 与 My Applications 链接。规范特别提醒:不要从独立页面去打开 Apply——Apply 固定在 brief 记录上。
新代码该放哪:一张决策表
AGENTS.md 以极简的“Where does new code go?”收尾,可直接当作写代码时的决策索引:
| 要写的代码 | 放哪 |
|---|---|
| 外部 API 调用 | connector/ |
| 裸 query/mutation | graphql/ |
| 任何业务逻辑 | services/ |
| 新的 SDK 触发器/函数 | 领域/功能根下的薄 *.logic-function.ts,只调 service |
| 跨领域共享 | modules/shared/ |
小结:一份面向 Agent 协作的“活架构文档”
twenty-partners 的 CLAUDE.md 用一个 @AGENTS.md 引用把文档组织成了“仓库级通用规范 + 应用级架构”两层,而 AGENTS.md 则以纵向切片、单向依赖、具名测试、UUID 不变量和“手册与行为锁步”几条铁律,把这个 300+ 文件的内部应用约束得清晰可维护。对读者而言,这套组织方式本身就是一份值得借鉴的模板:当业务应用规模超过“demo”量级时,如何用领域目录收敛声明与逻辑、用依赖箭头防止架构腐化、用不变量保护 SDK 的标识符契约——而这一切仍然运行在 Twenty CRM 之上,共享同一套对象、权限(见 src/roles)与视图体系,让 CRM 真正成为业务运营的“操作系统”。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00