架构
一次从按钮点击出发的旅程:穿过服务器、进入 Postgres、交给后台任务,再回到屏幕。
Carbon 在同一套制造数据模型上运行两个应用。ERP 管理报价、采购、计划、库存、会计和发货。MES 记录并控制车间工作,通常通过工作中心的平板操作。
本页追踪一次写入:从界面经过校验、授权、持久化、后台工作,直到界面刷新。请按顺序阅读第 1 部分和第 2 部分。其余部分是独立参考。
像 Postgres [17] 这样的编号引用链接到术语表中的定义。
快照,不是契约
文件路径、行号和计数截至 2026 年 8 月准确,可能随仓库演进变化。
第 1 部分:地图
Carbon 是一个 monorepo [10],包含若干应用和约 25 个共享库,由 pnpm workspaces 和 Turborepo [25] 管理。ERP 和 MES 是主要的用户界面应用。
一个 Postgres [17] 数据库是记录系统。它还执行行级权限、计算派生值并发出变更事件。第 3 部分说明这些职责。
每张图中,蓝色代表 Carbon 应用,粉色代表存储状态,琥珀色代表后台工作。
关键属性:
- ERP 应用是枢纽。 它提供页面并执行后台任务。Inngest [5] 安排工作并向
/api/inngest发送请求;由 ERP 容器执行代码。参见第 4 部分。 - 大多数数据库访问不是原始 SQL。 它通过 PostgREST [18] 进行,该服务把
client.from("purchaseOrder").select(...)翻译为 SQL。应用代码通过supabase-js访问。 - 数据库执行租户访问控制。 行级安全阻止访问其他公司的行,除非代码显式使用 service-role 客户端。参见第 3 部分。
- 部分写入会自动发出事件。 约 90 张表使用数据库触发器发起订阅的搜索索引、Webhook、审计日志和客户工作流。
- Rust 服务是可选的。 它把 CAD 文件转换为浏览器可渲染的 3D 模型,并为装配指导书规划拆卸顺序。没有它核心功能仍可运行。
第 2 部分:跟随一次点击深入到底
本节跟随采购员通过表单创建采购订单的完整过程:校验、权限检查、数据库写入、Postgres 逻辑、后台副作用和界面刷新。
简要版本
行写入后请求即完成。搜索索引异步运行。
同一约定,真实文件
以下路径指向截至 2026 年 8 月的实现。
1. 按钮。 它是一个链接,不是提交按钮。
apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrdersTable.tsx:502
primaryAction={
permissions.can("create", "purchasing") && (
<New label={t`Purchase Order`} to={path.to.newPurchaseOrder} />
)
}permissions.can(...) 隐藏用户无法执行的操作,但服务端会执行权威的权限检查。path.to.* 引用 apps/erp/app/utils/path.ts 中的路由注册表;不要硬编码应用 URL。
2. 表单。 apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrderForm.tsx:116
<ValidatedForm
method="post"
validator={purchaseOrderValidator}
defaultValues={initialValues}
>ValidatedForm 用基于 schema 的客户端和服务端错误显示包装 HTML 表单。<Input name="..."> 和 <Supplier name="..."> 等字段来自 ~/components/Form,它导出通用输入和 Carbon 专用选择器。
3. Schema。 apps/erp/app/modules/purchasing/purchasing.models.ts:127
export const purchaseOrderValidator = z.object({
id: zfd.text(z.string().optional()),
purchaseOrderId: zfd.text(z.string().optional()),
purchaseOrderType: z.enum(purchaseOrderTypeType, {
/* ... */
}),
supplierId: z.string().min(1, { message: "Supplier is required" }),
locationId: zfd.text(z.string().optional()),
});zod [28] 提供运行时校验和推导出的 TypeScript 类型。zod-form-data(zfd)规范化 HTML 表单值;例如 zfd.text() 把空字段从 "" 转换为 undefined。
浏览器和服务端针对同一 schema 校验,服务参数类型也从中推导。
4. 路由。 apps/erp/app/routes/x+/purchase-order+/new.tsx:29
export async function action({ request }: ActionFunctionArgs) {
assertIsPost(request);
const { client, companyId, companyGroupId, userId } =
await requirePermissions(request, {
create: "purchasing",
bypassRls: true,
});
const formData = await request.formData();
const validation = await validator(newPurchaseOrderValidator).validate(
formData
);
if (validation.error) return validationError(validation.error);
const result = await insertPurchaseOrder(client, {
...validation.data,
companyId,
companyGroupId,
createdBy: userId,
customFields: setCustomFields(formData),
});
if (result.error || !result.data) {
throw redirect(
path.to.purchaseOrders,
await flash(
request,
error(result.error, "Failed to insert purchase order")
)
);
}
throw redirect(path.to.purchaseOrder(result.data.id));
}Carbon 的写入遵循此顺序:
确认方法 → 检查权限 → 校验 → 调用服务 → 重定向。
apps/erp/app/routes/ 下的文件最多可导出三个在此相关的对象:
loader 和 action 不进入浏览器 JavaScript,因此可以使用密钥和仅服务端的数据库客户端。
5. 服务函数。 apps/erp/app/modules/purchasing/purchasing.service.ts:1393
路由调用服务函数,而不是直接访问数据库。服务函数以数据库客户端为第一个参数,并返回 Supabase 的 { data, error } 结果。
const seq = await client.rpc("get_next_sequence", {
sequence_name: "purchaseOrder",
company_id: input.companyId,
});
const order = await client
.from("purchaseOrder")
.insert({
purchaseOrderId,
supplierId: input.supplierId,
status: "Draft",
companyId: input.companyId,
createdBy: input.createdBy /* ... */,
})
.select("id, purchaseOrderId")
.single();
const [delivery, payment] = await Promise.all([
client.from("purchaseOrderDelivery").insert({
/* ... */
}),
client.from("purchaseOrderPayment").insert({
/* ... */
}),
]);
if (delivery.error || payment.error) {
await deletePurchaseOrder(client, orderId); // manual undo
return { data: null, error: delivery.error ?? payment.error };
}每次 supabase-js 的 .insert() 调用都是独立的 HTTP 请求,不属于事务。如果后续写入失败,先前的写入仍保持已提交,除非显式撤销。需要事务时请使用 Kysely [6]。参见第 3.3 部分。
6. 数据库侧行为。 Postgres 执行两项额外操作。
订单编号。 client.rpc("get_next_sequence", ...) 调用 RPC [21],即存在于数据库内部的函数(packages/database/supabase/migrations/20241115101526_rpc-get-next-sequence.sql)。它原子地递增计数器,并用 PO、补零和可选日期标记格式化结果。
事件。 每次 purchaseOrder 插入都会调用 Postgres 触发器。如果公司为该表和操作配置了有效订阅,触发器会排队一条 JSON 消息。路由本身不发起此行为。第 4 部分追踪该事件。
7. 屏幕更新。 操作以 throw redirect(...) 结束。React Router 跟随重定向,运行目标 loader,读取当前数据库状态并渲染页面。任何非 GET 提交后,React Router 会自动重新验证当前页面的 loader。
第 3 部分:逐层说明
3.1 浏览器与路由文件
应用使用React Router v7 的框架模式和服务端渲染 React。路由文件可以同时定义数据加载和界面。Carbon 的 ERP 和 MES 不使用 Remix 或 Next.js。Vite [27] 构建应用,Tailwind v4 提供样式。
路由是磁盘上的文件。 apps/erp/app/routes/ 约有 1,400 个,文件夹结构就是 URL 结构,并有以下约定:
| 文件名中 | 含义 |
|---|---|
x+/ | 一个 URL 段和文件夹:x+/purchase-order+/new.tsx → /x/purchase-order/new |
_public+/ | 仅用于分组的文件夹,不增加 URL 段 |
_layout.tsx | 文件夹内每个路由共享的包装器 |
$orderId | URL 参数:$orderId.tsx → /:orderId |
名称中的 . | URL 中的 /:dimensions.new.tsx → dimensions/new |
_index.tsx | 文件夹自身 URL 的页面 |
*.server.ts | 路由忽略;永不进入浏览器的辅助文件 |
顶层中,x+ 包含已认证的应用路由,api+ 包含 JSON 端点,_public+ 包含认证路由,share+ 包含基于令牌认证的公开链接。
数据进入。 服务端 loader 调用服务函数并返回 JSON。组件用 useLoaderData<typeof loader>() 读取。apps/erp/app/routes/x+/_layout.tsx 加载共享的用户、公司、权限和功能开关数据。useUser()、usePermissions() 和 useCompanySettings() 等钩子读取这些 loader 数据。
数据出去。 表单提交到 action,如第 2 部分所示。对于不应导航的写入(例如重新排序、内联编辑或删除),使用 useFetcher()。Fetcher 提交仍会触发自动重新验证。
共享界面。 添加组件前请检查这些位置:
packages/react/:设计系统(@carbon/react)。按钮、模态框、组合框、菜单和布局原语,基于 Radix UI [19] 和 Tailwind。apps/erp/app/components/:应用级组件。最重要的是components/Table/Table.tsx,一个通用的 TanStack Table 实现,供列表界面使用。它处理排序、筛选、无限滚动、保存视图、CSV 导入导出、列可见性和内联编辑。路由 loader 返回前 100 行和游标。用户滚动时,表格用?after=<cursor>从同一 loader 加载下 100 行,并只渲染可见行。
客户端状态有限。共享参考列表使用 nanostores [13],由 RealtimeDataProvider 刷新。临时界面状态使用 zustand [29]。用户和权限数据来自路由 loader。
翻译使用 Lingui [7]。用 t`...` 或 <Trans> 标记字符串,然后运行 pnpm lingui:extract。
MES 使用相同技术栈,但结构更扁平。 它没有 modules/ 文件夹:服务在 apps/mes/app/services/,所有校验器在一个 models.ts 中,路由只有一层,界面为戴手套在平板上操作而设计。
3.2 关卡:谁被允许做什么
服务端入口使用 requirePermissions 进行认证和授权。
const { client, companyId, userId } = await requirePermissions(request, {
create: "purchasing",
});定义在 packages/auth/src/services/auth.server.ts:195,它执行四项操作:
- 识别调用者:来自浏览器会话 cookie,或来自公共 API 的限流、校验作用域的
carbon-key头。 - 加载其权限。 权限以
<module>_<action>键存储,例如purchasing_create或sales_view,每个映射到用户持有该权限的公司列表。它们由 Postgres 函数get_claims获取,并在 Redis 中缓存一小时。它们不在 JWT 中。 - 拒绝未授权请求。 未认证用户被重定向到登录;未授权用户收到 Access Denied(拒绝访问) 消息。
- 返回用户作用域的数据库客户端、
companyId和userId。
Carbon 使用多租户 [12]:一个数据库服务多家公司。几乎每张表都有 companyId 和复合主键 ("id", "companyId")。当前公司来自会话,而不是 URL。
行级安全(RLS) [20]把每张表的访问策略加入数据库查询。例如:
CREATE POLICY "SELECT" ON "public"."reportPin"
FOR SELECT USING (
"companyId" = ANY ((SELECT get_companies_with_employee_role())::text[])
);查询其他公司的记录返回空行。服务函数还会按 companyId 过滤,实现纵深防御。
逃生通道。 bypassRls: true 返回 service-role [22] 客户端,用于性能敏感路径上的可信员工请求。由于该客户端绕过 RLS,每个 loader 必须显式校验租户归属:
if (purchaseOrder.data?.companyId !== companyId)
throw redirect(path.to.purchaseOrders);公司成员关系分配三种角色之一:employee、customer 或 supplier。门户用户权限更窄,路由可以要求 { role: "employee" }。share+/ 路由使用不可猜测的 URL 令牌而不是登录认证。
3.3 服务层:通往数据库的四扇门
服务函数以数据库客户端为第一个参数。根据所需的安全性和事务行为选择客户端。
1. 用户作用域客户端。 requirePermissions 通常返回的客户端。每个查询都以该用户身份运行并应用 RLS。除非有理由不用,否则请使用它。
2. service-role 客户端(getCarbonServiceRole())绕过 RLS。后台任务、share+/ 路由和 bypassRls: true 请求使用它。每个查询必须显式执行 companyId。
3. Kysely 是 TypeScript SQL 查询构建器,持有真正的 Postgres 连接,因此可以打开真实事务。这是第 2 部分中「三次插入不具原子性」问题的答案:
// updateSortOrder (apps/erp/app/modules/shared/sort-order.ts), which every
// drag-to-reorder route uses: one UPDATE for all the rows, inside a transaction.
await db.transaction().execute(async (trx) => {
const { rows } = await sql<{ id: string }>`
UPDATE "quoteLine" AS t SET "sortOrder" = v."sortOrder", …
FROM (VALUES ${values}) AS v("id", "sortOrder")
WHERE t."id" = v."id"
AND t."companyId" = ${companyId}
AND t."quoteId" = ${quoteId}
RETURNING t."id"`.execute(trx);
// A row from another company or another quote matched nothing. Throwing
// here rolls back the rows that did match: all or nothing.
if (rows.length !== updates.length) throw new Error("…");
});Kysely 在失败时抛出异常,且不应用 RLS。请用 try/catch 包装调用,在执行前授权,并按 companyId 限定每条语句;当行 ID 来自请求时,还要按父单据 ID 限定。用一条语句更新集合,而不是循环查询。Node 进程共享一个 16 连接的池(getProcessPool())。事务内只使用提供的 trx 句柄;通过 db 请求另一个连接可能死锁直到超时。
4. 过账(Postings) 是 ERP、MES、公共 API 和后台任务共享的特权多步写入。大约有五十个,每个都是其 ERP 模块服务文件中的构建块(例如 apps/erp/app/modules/inventory/inventory.service.ts 中的 postShipment),它们承担真正繁重的写入:把发货或发票过账到总账、发料、转换报价、CSV 导入。每个都会校验输入,并在调用者的权限(其 <name>Permission 规则)检查后运行,invokePosting 返回 { data, error } 而不是抛出。操作这样调用:
import * as inventory from "~/modules/inventory/inventory.service";
import { invokePosting } from "~/modules/shared/shared.service";
const { data, error } = await invokePosting(
ctx,
"post-shipment",
{ type: "post", shipmentId },
inventory.postShipment
);过账运行大型 Kysely 事务,可在服务端任何位置调用:操作、MES(通过 HTTP,erpPosting)、后台任务(@carbon/api/postings.server 的 posting)或公共 API。
保持函数代码精简,在 Postgres 中执行基于集合的工作。需要运行数分钟的工作使用 Inngest,计算密集的 CAD 几何使用 Rust 服务。MRP [11] 和生产排程位于 @carbon/planning,同样在 Node 中运行。
一个为 Deno [2] 编写的小型边缘函数 [4] 仍位于 packages/database/supabase/functions/:embedding。它自包含,通过 HTTP 用 client.functions.invoke("embedding", { body }) 调用。
RPC 是更窄的第五条路径:用 client.rpc("name", args) 调用 Postgres 函数。第 2 部分中的 get_next_sequence 就是一例。
3.4 数据库
Carbon 通过 Supabase [24] 使用 Postgres 15,后者增加 PostgREST、认证、文件存储、实时变更和边缘函数运行时。
截至 2026 年 8 月,schema 包含 923 个迁移文件、362 张表、101 个视图、238 个数据库函数和 154 个枚举类型。
每个迁移 [9] 是 packages/database/supabase/migrations/ 中带时间戳的 SQL 文件,命名为 YYYYMMDDHHMMSS_kebab-name.sql。用 pnpm db:migrate:new <name> 创建,用 pnpm db:migrate 应用。它们按文件名顺序应用,合并后永不编辑。
一张典型表同时展示所有约定:
CREATE TABLE "itarCertification" (
"id" TEXT NOT NULL DEFAULT id('itc'),
"companyId" TEXT NOT NULL,
"createdBy" TEXT NOT NULL REFERENCES "user"("id"),
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
"updatedBy" TEXT REFERENCES "user"("id"),
"updatedAt" TIMESTAMP WITH TIME ZONE,
PRIMARY KEY ("id", "companyId"),
FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE
);
ALTER TABLE "public"."itarCertification" ENABLE ROW LEVEL SECURITY;id('itc') 生成带前缀、可排序、URL 安全的 ID,例如 itc_3xK9...。
视图 [26]是主要读取路径。Carbon 写入表并从视图读取。例如,复数 purchaseOrders 视图连接供应商名称、计算 orderTotal、转换货币、统计已收数量并获取缩略图。写入路径插入源数据;视图派生显示值。
视图在迁移中用 WITH (security_invoker = true) 定义,因此调用者的 RLS 策略生效。
数据库类型是生成的。 pnpm run generate:types 读取本地 schema 并写入 packages/database/src/types.ts,描述每张表、视图、函数和枚举。应用代码从中导入:
import type { Database } from "@carbon/database";
type PurchaseOrderRow = Database["public"]["Tables"]["purchaseOrder"]["Row"];拉取或创建迁移后、类型检查前,请运行 pnpm run generate:types。不要直接编辑生成的类型。
数据库逻辑在 ERP、MES、过账、后台任务、公共 API 和直接 SQL 之间执行不变式。例如:
update_picking_list_status()是触发器,在拣货单任一行变化时重新计算表头状态。它从四个不同位置写入;在每个调用方重新计算会产生漂移。dispatch_event_batch()是第 4 部分的事件触发器。它必须在导致写入的同一事务中入队,因此回滚的写入绝不会发出幻影事件。
文件存放在 Supabase Storage 桶中:private 是主力(附件、图纸、质量记录),public 和 avatars 顾名思义,另有每公司一个桶以及用于大型 CAD 上传的 temp-staging。
第 4 部分:写入之后发生什么
事件处理独立于执行写入的代码。只有迁移用 attach_event_trigger('tableName') 注册表后,表才会发出事件。触发器仅在公司为该表和操作配置了有效订阅时才入队。因此同一个 UPDATE 可以为一公司调用多个处理器,而对另一公司不调用任何处理器。
PGMQ [16] 把队列存储在 Postgres 中,使触发器能在同一事务中入队。提交后,pg_net [15] 向 Inngest [5](后台任务运行器)发送 HTTP 请求。
Inngest 把具名事件映射到已注册的 HTTP 函数并持久化步骤结果。每个 step.run("id", fn) 都是一个检查点;重试从已完成步骤之后继续,而不是重新执行它们。
接线涉及三个文件:
| 内容 | 位置 |
|---|---|
| 客户端 | packages/lib/src/inngest/client.ts |
| 全部 55 个任务函数的列表 | packages/jobs/src/inngest/index.ts |
| Inngest 调用的 HTTP 端点 | apps/erp/app/routes/api+/inngest.ts |
后台任务在 ERP 容器内运行。 Inngest 安排请求并持久化执行状态;它不托管 Carbon 的任务代码。
按需触发任务。 需要显式启动任务时,调用带类型的 trigger() 辅助函数:
await trigger("notify", {
event: NotificationEvent.JobAssignment /* ... */,
});事件名始终为 carbon/<something>。
定时工作有两个来源。Inngest cron 计划运行应用任务:MRP(每十五分钟一次滴答,每三小时计划每家公司,或按公司自己时钟设置的每天一次)、汇率和 nightly 审计归档。cron 计划本身是 UTC,需要公司本地日期或时间的任务在运行内部计算。数据库级维护改用 pg_cron [14],用于刷新缓存聚合表、清扫事件队列和清除旧通知。pg_cron 还决定两个应用任务何时运行:每十五分钟检查是否有通知应汇总为摘要,04:00 检查是否有工作流运行历史需要清理,并且只有存在工作时才唤醒 Inngest 函数。
通知源自 packages/jobs/.../notifications/notify.ts,目标为应用内记录、Resend 邮件和 Slack。顶栏通过 Realtime 接收应用内记录。packages/notifications 定义事件类型但不发送消息。
第 5 部分:其他引擎
工作流:客户自己的自动化
客户在拖放画布上配置工作流。这些工作流与 Inngest 任务相互独立:
| Inngest 任务 | 工作流 | |
|---|---|---|
| 编写者 | 我们,用 TypeScript | 客户,在画布上 |
| 存储为 | 代码 | workflowVersion 中的 JSON 行 |
| 运行身份 | service role,能看到一切 | 工作流的所有者,应用其 RLS |
| 变更方式 | 部署 | 客户随时 |
第 4 部分事件管道的 WORKFLOW 分支把行变更映射到目录事件,查找订阅的工作流,并为每个排队一次运行。packages/workflows 包含纯定义和校验逻辑。packages/jobs/src/workflows/ 包含运行时。Carbon 记录每次运行和步骤以供检查。
Assembler:Rust 服务
apps/assembler 加 crates/ 是一个 Rust HTTP 服务,把 CAD 文件(STEP)转换为 Web 可渲染的 3D(GLB),并为装配指导书规划无碰撞拆卸顺序。它封装两个 C++ 库(OpenCASCADE 用于几何,FCL 用于碰撞)。packages/viewer 在浏览器中渲染其输出。该服务是可选的;其他组件没有它也能启动。
API 是异步的。调用者提交工作,获得任务 ID,等待回调或轮询作为回退。服务通过签名存储 URL 读写,不经过 ERP 路由文件,也不持有数据库凭据。Redis 保存任务状态和结果指针,启动时必须可用。
第 6 部分:仓库地图
应用位于 apps/ 下,根目录的 docs/ 应用除外:
| 应用 | 是什么 | 开发端口 |
|---|---|---|
erp | 主产品,也是所有后台任务的宿主。规模最大。 | 3000 |
mes | 车间:工单工序、工作中心、排程。触屏优先。 | 3001 |
academy | 面向 Carbon 新用户的培训应用。 | 4111 |
starter | 展示认证 + 数据库 + 表单的最小示例应用。 | 4000 |
assembler | Rust CAD/3D 服务。 | 视情况 |
docs | 公开文档站点。这里唯一的 Next.js 应用。 | 3002 |
库位于 packages/ 下:
| 包 | 作用 |
|---|---|
react | 设计系统。写任何界面之前先看这里。 |
database | 生成的类型、迁移、边缘函数、数据库客户端。 |
planning | MRP 和有限产能排程器。 |
auth | 登录、会话、requirePermissions、API 密钥、权限缓存。 |
form | ValidatedForm、字段组件、zod 辅助。 |
jobs | 每个 Inngest 后台函数,以及工作流运行时。 |
lib | 共享服务端工具:Inngest 客户端、trigger()、邮件、Slack。 |
utils | 纯辅助:日期、金额、数组、字符串、BOM 计算。 |
workflows | 客户工作流 schema、校验和纯运行时。无 I/O。 |
documents | PDF 生成、邮件模板、条码和标签渲染。 |
locale | Lingui 设置和语言切换。 |
env | 读取和校验环境变量的唯一位置。 |
logger | 结构化日志,带请求 ID 关联。 |
notifications | 通知事件分类(仅枚举)。 |
printing | 打印机注册表、标签队列、物理打印投递。 |
onboarding | 新公司设置内容。 |
kv | Redis 客户端和限流。 |
stripe | 计费。仅 Cloud 版。 |
ee | 仅企业版代码:Xero、Slack、Jira、Linear、Onshape、套餐门控。 |
viewer | 3D 模型和装配指导书渲染。 |
tiptap | 富文本编辑器。 |
dev | 运行本地栈的 crbn CLI。 |
checks | CI 执行的仓库一致性规则。 |
config | 共享 tsconfig、Tailwind 主题、vitest 预设。无运行时代码。 |
glossary | 字段帮助中使用的规范制造术语定义。 |
harness | 内部 AI 智能体工具。不发布。 |
ERP 模块内部(共十八个,位于 apps/erp/app/modules/{module}/):
purchasing/
├── purchasing.models.ts zod schemas and the types derived from them
├── purchasing.service.ts every database operation for this module
├── purchasing.server.ts server-only helpers (optional)
├── types.ts shared types
├── index.ts barrel, import from "~/modules/purchasing"
└── ui/ React components, grouped by feature第 7 部分:在哪里运行
同一代码库在本地、Carbon 运营的云环境和客户管理的服务器上运行。环境配置决定部署模式。
7.1 在笔记本上
pnpm dev 运行 crbn up。CLI 启动十一个容器的服务栈,等待 Postgres、应用迁移、重新生成数据库类型、初始化测试用户,并以宿主 Node 进程启动应用以支持热重载。
每个 Git worktree 获得隔离的端口、数据库和凭据。Worktree 共享一个 Redis 容器,但使用各自编号的数据库。
7.2 Carbon 运营的云
Carbon 在 Vercel 上运营共享云,并在 AWS 上运营客户专用安装。
共享云运行在 Vercel 上。 app.carbon.ms(ERP)、mes.carbon.ms(MES)、learn.carbon.ms(academy)和 docs.carbon.ms 都解析到 Vercel,另有独立的欧盟区域(app.eu.carbon.ms、mes.eu.carbon.ms)由自己的一对项目提供服务。每个 React Router 应用通过其 react-router.config.ts 选择加入:当存在 VERCEL 环境变量时启用 @vercel/react-router 的 vercelPreset(),否则不做任何事。因此同一份源码既可为 Vercel 构建,也可为容器构建。Vercel 的 Git 集成执行部署。
专用安装运行在 AWS 上。 ERP 和 MES Docker 镜像运行在 ECS Fargate [3] 上。每个服务使用端口 3000 的负载均衡器、/health 检查和基于 CPU 与内存的 1 到 10 个任务自动扩缩容。sst.config.ts 用 SST [23] 定义这些基础设施。AWS + SST 方案部署同一套栈。itar.carbon.ms 运行在 AWS GovCloud。
每个 AWS 客户有独立安装。 每次部署时,ci/src/deploy.ts 读取 workspaces 表,并为每行运行一次 sst deploy --stage prod,各自使用该客户的 AWS 账户、区域、域名、证书、Supabase 项目和 Redis。客户在安装内按 companyId 隔离(第 3 部分),并在基础设施上隔离。新增 AWS 客户需要一行 workspace 记录。如果必需字段缺失,部署会跳过该行并记录遗漏。
两条路径的 Postgres 都是托管 Supabase。只有一个 Lambda:Assembler 以单个镜像发布,可作为 API 网关后的 Lambda 函数(默认)或容器运行,因此可以为每个客户开启 3D 转换而无需改动主栈。
7.3 在客户自己的服务器上
contrib/deploying/simple-docker-caddy/ 包含完整的自托管栈:一个 Docker Swarm 文件,在一台机器上启动 Postgres、Supabase 服务、Redis、Inngest 和两个应用,Caddy 前置并只暴露到互联网。密钥使用 Swarm 密钥而不是文件中的环境变量。迁移作为一次性容器在应用滚动更新前运行。scripts/backup.sh 创建数据库转储和上传文件归档,scripts/harden.sh 配置防火墙。参见备份。
有哪些功能由一个变量决定:CARBON_EDITION——community、cloud、enterprise 或 test。它们各自提供什么取决于许可模式。自托管默认 community;云端分发设置为 enterprise。
7.4 一次合并如何进入生产
推送到 main 会触发每个 Vercel 项目的 Git 集成构建。AWS 部署使用这些 GitHub Actions 工作流:
| 工作流 | 触发条件 | 作用 |
|---|---|---|
check.yml | 每个拉取请求 | Biome、类型检查、测试、翻译和工作流目录检查;一个边缘函数任务会拒绝函数目录外的任何 ../ 导入,然后运行 deno check |
build-images.yml | 每次合并到 main | 构建或重新标记每个镜像,以提交打标签;当合并触及 apps/erp、apps/mes、packages 或 docs/content 且两个应用镜像都成功时,调用 deploy.yml |
deploy.yml | 由 build-images.yml 调用,或手动运行 | 按 workspace 分发 sst deploy,然后调用 post-deploy.yml |
supabase.yml | 合并触及 packages/database/supabase | 按 workspace 应用迁移并部署边缘函数 |
post-deploy.yml | 每次 ERP 部署后:AWS 上由 deploy.yml 调用,或 Vercel 生产部署成功后 | 对每个应用调用 PUT /api/inngest,使 Inngest 了解当前任务列表,并把每个 ERP 的认证邮件复制到其托管 Supabase 项目 |
check.yml 不构建应用镜像,因此成功的拉取请求仍可能在镜像构建时失败。数据库和应用部署没有顺序,因此迁移必须与上一个应用版本保持兼容。
没有版本标记,也没有回滚按钮。镜像按提交标识,回滚意味着重新部署较旧的提交。配置也不在密钥管理器中:部署时从 workspaces 表读取值,并作为普通环境变量设置在容器上。
7.5 了解正在发生什么
LogTape 在本地输出彩色日志,在生产中输出按行分隔的 JSON。它会脱敏敏感字段并传播请求 ID。Carbon 不提供单独的日志传输;AWS 上使用 CloudWatch,自托管时使用 docker service logs。PostHog 提供产品分析,在受控环境中禁用。Carbon 不使用 Sentry 或分布式追踪。
第 8 部分:有 bug,我该看哪里?
从观察到的行为开始,沿请求路径向内追踪。
常见故障模式:
- TypeScript 说某列不存在。 运行
pnpm run generate:types。生成的类型落后于迁移。 - 查询没有返回任何内容,但行确实存在。 RLS。要么
companyId错了,要么在需要 service role 的地方使用了用户作用域客户端。 - 写入只成功了一半。 链式
supabase-js调用不是事务。寻找未覆盖该失败的手工回滚。 - 列表界面上的数字不对。 检查计算它的视图。
第 9 部分:团队规约
根目录 AGENTS.md 包含完整规则。关键约束:
- 用
pnpm,绝不用npm。 - 绝不用 JavaScript
Date进行解析、格式化或运算。使用@internationalized/date和@carbon/utils。构建检查会强制执行。制造排程中的时区不是装饰性的。 - 绝不手工编辑生成的数据库类型。 请重新生成。
- 绝不跳过
companyId限定,即使 RLS 可能会捕获。 - 绝不把
supabase-js写入串联起来当作事务。 使用 Kysely 或 RPC。 - 绝不在循环中查询。 收集 ID 并做一次
.in()调用。 - 每个模块一个
{module}.service.ts和一个{module}.models.ts。 不要分散。 - 任何迁移之后、类型检查之前,运行
pnpm run generate:types。
常用命令:
pnpm dev # boot everything
pnpm db:migrate:new <name> # create a migration
pnpm db:migrate # apply migrations
pnpm run generate:types # regenerate database types
pnpm exec turbo run typecheck --filter=erp # typecheck one package
pnpm run lint # Biome
pnpm run test # unit tests用 --filter 对单个包做类型检查;全仓库类型检查可能耗尽内存。
术语表
本页使用的编号引用,按字母顺序排列。
1. Action: 路由文件导出的、用于处理表单提交的函数。仅在服务端运行,从不发送到浏览器。
2. Deno: Node.js 的另一种 JavaScript 运行时。我们仅剩的边缘函数(embedding)为它编写,因此使用 https:// 导入而不是 node_modules。
3. ECS Fargate: Amazon 的无服务器容器运行服务。生产中的 ERP 和 MES 运行于此。
4. 边缘函数: 部署在数据库旁的小型服务端程序,通过 HTTP 调用并像普通函数一样等待。我们的是为 Deno 编写的。
5. Inngest: 后台任务运行器。向它发送事件;它通过 HTTP 调用你的函数并记住每个已完成步骤,因此重试是继续而不是重新开始。
6. Kysely: TypeScript SQL 查询构建器。与 supabase-js 不同,它持有真正的数据库连接,因此可以运行真实事务。它也会绕过 RLS。
7. Lingui: 翻译库。你在代码中标记字符串;它提取为按语言的文件供译者使用。
8. Loader: 路由文件导出的、用于获取页面所需数据的函数。仅在服务端运行。
9. 迁移: 描述 schema 变更的带时间戳 SQL 文件。它们完整有序的集合就是schema;没有单独的 schema 文件。
10. Monorepo: 一个 Git 仓库,包含多个一起版本化和发布的应用与共享库。
11. MRP: 物料需求计划。根据需求和当前库存计算出买什么、造什么以及何时进行。在 Node 中运行,来自 @carbon/planning。
12. 多租户: 一个数据库和一个运行中的应用服务多家独立客户公司,各公司数据互不可见。
13. nanostores: 非常小的状态库。我们用它保存许多界面读取、由 Realtime 保持最新的参考列表(物料、客户、供应商)。
14. pg_cron: Postgres 扩展,在数据库内按计划运行 SQL。用于缓存刷新和队列清扫。
15. pg_net: Postgres 扩展,让数据库发起出站 HTTP 调用。我们用它把提交后的事件直接发送到 Inngest,尤其是唤醒事件队列的 ping。
16. PGMQ: 用普通 Postgres 表实现的消息队列。让数据库触发器在导致写入的同一事务中入队。
17. Postgres: Carbon 一切构建于其上的关系数据库。版本 15。
18. PostgREST: 把 Postgres 表和视图暴露为 REST API 的服务。每个 client.from(...).select(...) 都是 PostgREST 调用,而不是原始 SQL。
19. Radix UI: 无样式、可访问的 React 组件库。我们的设计系统在其上添加 Tailwind 样式。
20. RLS(行级安全): Postgres 特性,把每张表的规则自动加入每个查询。它让一家公司的数据对另一家不可见。
21. RPC: 此处指从应用代码通过 client.rpc("name", args) 调用 Postgres 内定义的函数。
22. Service role: 绕过所有 RLS 的数据库凭据。功能强大,请谨慎使用。
23. SST: 用 TypeScript 编写的 AWS 基础设施即代码。sst.config.ts 定义我们的生产集群。
24. Supabase: 围绕 Postgres 的开源套件:REST API、认证、存储、实时和边缘函数。本地运行其容器,生产使用托管版本。
25. Turborepo: 构建编排器,知道哪些包依赖哪些包,因此 pnpm build 只重建变化的部分。
26. 视图: 行为类似只读表的保存查询。Carbon 写入表、从视图读取;订单合计等计算值位于视图中。
27. Vite: 所有 React 应用背后的构建工具和开发服务器。
28. zod: schema 库。描述一次数据,从同一份定义获得校验和 TypeScript 类型。
29. zustand: 小型 React 状态库。我们仅用于临时界面状态,例如模态框是否打开。