概览
Alforse 由两个共用同一制造数据模型的应用组成:
两者背后是同一个后端。
如何阅读本页
先从系统一览开始,再阅读端到端跟踪一次写入;第二部分以第一部分为前提。此后的各节自成一体,可以按任意顺序阅读。 技术术语链接到本页末尾术语表中的简短定义;例如 Postgres。系统一览
所有内容都位于由 pnpm 工作区和 Turborepo 运行的单个 monorepo 中,分为两层。界面层
两个 React Router 应用apps/erp 和 apps/mes 在服务器上渲染其 React。它们共有的代码打包在 packages/web/* 下;其中的包括 auth、database、api、jobs、planning、react、utils 和 workflows。
后端
只有一个应用主机,即apps/backend。除了提供 API,它还组合了以下原生层:
所有数据都存放在同一个 Postgres 数据库中,它是记录系统。它按公司实施行级权限,并且还包含向后台任务供给数据的事务性发件箱。每个部分在逐层剖析中说明。
塑造一切的五个特性
- 界面从不直接访问数据库。 ERP 和 MES 中的服务端代码与挂载在
/api/web的后端网关通信。网关理解界面最初所编写的同一套 REST、RPC、认证和存储协议,并把每个调用转交给位于kernel/、modules/、products/或packages/database/之一的原生所有者。 - 数据库将租户隔离开。 行级安全意味着一个公司的行无法从另一个公司访问;认证与授权解释了其原理。
- 相关的写入作为一个整体提交。 原生所有者参与调用方的工作单元,它引发的任何事件都在同一个事务内进入发件箱。
- 没有单独的作业系统。 后端进程内的运行器处理定时和排队的任务,而投递工作进程发送通知和 Webhook。详情见写入之后。
- CAD 处理同样在后端。 几何转换和装配规划都发生在
/api/web/assembler之后;不需要为它们另外部署任何东西。
端到端跟踪一次写入
示例:采购员保存一张新的采购订单。跟随请求依次经过表单、校验、权限检查、写入数据库、分配单据编号,最后是刷新后的界面。一张图看完整个过程
行一旦存储,请求就完成了。通知和 Webhook 等副作用稍后才发生,由发件箱取走处理。在代码中逐步查看
1. 按钮
apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrdersTable.tsx:535
permissions.can(...) 隐藏用户无权执行的内容,纯粹是一种体贴;真正起作用的决定在服务器上做出。链接通过 path.to.* 走,即 apps/erp/app/utils/path.ts 中的路由注册表,因此应用 URL 从不手写。
2. 表单
apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrderForm.tsx:116
表单组件是 ValidatedForm(包 @alforse/web-form)。底层它是普通的 HTML 表单;在其之上,它显示模式(schema)的错误,无论这些错误来自浏览器还是服务器。它通过属性接收校验器和默认值。
3. 模式
apps/erp/app/modules/purchasing/purchasing.models.ts:146
规则位于 purchaseOrderValidator 中,这是一个 zod 对象,既产生运行时检查,也产生匹配的 TypeScript 类型。HTML 表单值由 zod-form-data(zfd)清理:例如,zfd.text() 会把空字段的 "" 映射为 undefined。浏览器和服务器共用这一个模式,因此应用相同的规则。
4. 路由
apps/erp/app/routes/x+/purchase-order+/new.tsx:30
apps/erp/app/routes/ 下的路由文件可以导出三样与此流程相关的东西:
loader 和 action 都不会进入浏览器包,这就是二者可以使用仅限服务器的代码的原因。
5. 服务函数
apps/erp/app/modules/purchasing/purchasing.service.ts:1663(insertPurchaseOrder)
数据访问委托给服务函数,绝不在路由中进行。每个服务函数接收一个客户端作为第一个参数,并以 supabase-js 中熟悉的 { data, error } 形式作答。该客户端由 getAlforseServiceRole()(packages/web/auth/src/lib/supabase/client.server.ts:13)构建;尽管名称沿用旧称,它是以本次请求中登录的用户身份与 /api/web 上的原生网关通信。客户端属于某一个请求,在任何其他请求中都无法使用。
6. 数据库中的编号
调用client.rpc("get_next_sequence", ...) 是一个 RPC,由编号模块 modules/master-document-numbering/ 应答。在与提交相同的事务内,该模块领取下一个计数值,并为其应用公司的前缀、填充位数和日期标记。格式见编号序列。
7. 回到界面
最后,action 执行throw redirect(...)。随后 React Router 加载目标页面:其 loader 通过网关获取最新状态,页面渲染。另外,在每次非 GET 提交之后,React Router 会自行重新运行当前页面的 loader。
逐层剖析
浏览器与路由文件
两个界面应用都构建在框架模式下的 React Router v7 上,并在服务器上渲染 React,这让单个路由文件可以同时承载其数据加载和界面。ERP 或 MES 界面不使用 Remix 和 Next.js;构建由 Vite 完成,样式使用 Tailwind v4。只有apps/backend 例外,它是一个 Next.js 应用主机。
路由即文件
apps/erp/app/routes/ 包含 1,600 多个文件,直接映射到 URL 空间:借助 remix-flat-routes(在 apps/erp/app/routes.ts 中配置),文件夹布局_就是_ URL 布局。
顶层文件夹按受众划分应用:
读取数据
页面通过服务器上的loader 获取数据,它调用服务函数并返回 JSON;组件通过 useLoaderData<typeof loader>() 接收。每个页面都需要的数据(用户、公司、权限和功能标志)由 apps/erp/app/routes/x+/_layout.tsx 一次加载。组件通过 apps/erp/app/hooks/ 中的钩子访问,例如 useUser()、usePermissions() 和 useCompanySettings()。
从界面写入数据
表单提交到其路由的action,与被跟踪的写入中完全一致。当用户应停留在页面上时,例如重新排序行、行内编辑或删除,请改用 useFetcher() 提交;页面之后仍会重新验证。
共享界面
编写新组件之前,先看看这些地方:浏览器中的状态
浏览器保存的状态非常少。到处使用的参考列表(物料、客户、供应商、人员)保存在apps/erp/app/stores/ 下的存储中,并由 RealtimeDataProvider 刷新。一次性的界面状态放在 zustand 中。用户及其权限的来源始终是路由 loader。
翻译
文本使用 Lingui 翻译:把字符串包在t`...` 或 <Trans> 中,然后运行 pnpm lingui:compile 构建目录。
MES
MES 共用同一技术栈,但结构更扁平。它没有modules/ 文件夹;其服务位于 apps/mes/app/services/,大约 100 个路由处于同一层级,界面按戴手套的手在平板电脑上操作的尺寸设计。
认证与授权
没有任何服务端入口可以绕过requirePermissions,它同时处理认证和授权:
packages/web/auth/src/services/auth.server.ts:152。一次调用要经过四个阶段:
- 识别调用方。 浏览器请求携带原生会话 cookie;公共 API 请求携带
alforse-key标头。对于密钥,原生密钥所有者会根据路由所需的权限验证密钥并应用速率限制。详情见 API 密钥。 - 请求决定。 路由所需的权限取自
view、create、update、delete,以及可选的role,会发送给后端的原生授权所有者。它依据用户在所选公司中的授权作出决定。授权来自三个来源的叠加:员工类型、用户所属的组,以及直接分配。 - 拒绝不被允许的请求。 没有有效会话(无会话或已过期)时,会话会被销毁,用户被带到登录页。缺少权限则产生携带 Access Denied 消息的重定向。如果返回的决定对应的用户或公司与会话中的不一致,请求会作为身份不一致被拒绝。
- 交回上下文。 成功时,路由会得到一个限定于本次请求的客户端,以及
companyId、companyGroupId和userId。
多租户与行级安全
许多公司共用一个数据库,这使 Alforse 成为多租户。当前活动的公司由会话决定;URL 不起作用。 租户表启用并强制了行级安全(RLS);基线迁移packages/database/migrations/0001_baseline.sql 通过 ALTER TABLE ... FORCE ROW LEVEL SECURITY 实现。在每个事务开始时,当前公司和操作者会被保存为事务局部设置,每条策略都据此检查行。请求另一个公司的记录,结果为空。
平台与工作区相互分离
工作区生命周期、套餐和权益由平台控制平面管理,这是一个与各客户工作区分离的安全域。平台一侧的权限不会隐含读取客户业务记录的权利。其他类型的用户
使用门户的客户和供应商获得更窄的权限,任何路由都可以要求{ role: "employee" }。在 share+/ 下,路由完全不需要登录:访问取决于 URL 中携带的不可猜测的令牌。
写入数据
ERP 和 MES 中的界面保留了它们最初编写时的数据访问风格。后端并不是交出数据库连接,而是把每种风格转换为一个原生所有者。共有四条路径。1. 网关客户端
2. 命名的原生操作
不少界面通过packages/web/database/src/client.ts 中的 getPostgresClient,使用 Kysely 来描述查询。构建器从不输出 SQL。它只记录查询的样子,并将其与 packages/web/database/native-operations/ 中注册表里恰好一个命名操作匹配,该目录包含 purchasing.ts、inventory.ts、production-* 和 quality.ts 等文件。不匹配任何操作的查询会被拒绝,并且拒绝信息会指明它。像拖动重新排序这样的多行写入(apps/erp/app/modules/shared/sort-order.ts)依赖这一点来实现全有或全无。
3. 原生所有者与工作单元
重要的业务写入实现为products/* 和 packages/database/src/ 中的类;过账发货或发票、发料、完成生产数量都是例子。每个都在一个工作单元内运行,即整个业务事务使用同一个 Postgres 连接,并在其上设置操作者和公司。正文中的任何失败都会让全部回滚,而旨在成功写入之后执行的钩子只会在提交发生后触发。
4. RPC
最窄的路径是以client.rpc("name", args) 调用的命名函数;被跟踪的写入中的 get_next_sequence 就是一例。每个 RPC 都映射有一个原生所有者,或出现在明确的未映射 RPC 列表中。
计划是独立的领域:MRP 和生产排程在 packages/web/planning 中与计划所有者一同实现。
数据库
数据库是 PostgreSQL,前面没有任何东西:没有 PostgREST,没有 Supabase 项目,也没有边缘函数。后端是数据库唯一的客户端,并以专用角色连接,即受 RLS 约束的运行时角色,以及调度器和投递工作进程各自独立的角色。迁移
每个迁移是packages/database/migrations/ 中一个带编号的 SQL 文件。
这些规则记录在
packages/database/migrations/README.md 中;运行器是 packages/database/src/migrations.ts。
模式与表
基线带来大约 740 张表,分布在两个模式中。platform 服务于控制平面和原生所有者;web 是界面读取的契约模式。视图提供读取侧。
标识符
ID 由应用而不是 Postgres 生成,形如{prefix}_{base58(uuidv7)},存储在 text 主键中。每种记录类型拥有一个其他类型不使用的前缀。结果按创建时间排序,并且在日志行或 URL 中仍然易读。
读与写
规则是通过所有者写入,从关系读取。写入只存储源数据。界面在其之上显示的一切,例如合计、名称和缩略图,都由读取关系派生。文件
上传的文件被写入对象存储,界面通过/api/web/storage 路由访问。支持三种提供商类型:Cloudflare R2、任何 S3 兼容存储,以及客户存储网关(见 apps/backend/src/production/blob-storage-provider.ts 及其相邻文件)。每个公司拥有自己的键命名空间和配额,并且可以添加备份副本配置。
写入之后:事件与后台任务
执行写入的代码并不同时运行后续工作。相反,所有者会在产生业务变更的那个事务内,向事务性发件箱(platform."outbox")添加一个领域事件。如果该事务回滚,事件随之消失;如果提交,事件必定存在。
投递保证
投递为至少一次,这就是处理程序被编写为幂等的原因。临时故障会按带尝试次数上限和有上限的指数退避的策略重试;永久故障则停止。kernel/events/ 包含事件总线、该重试策略和 Webhook 投递。
原生运行器
后台任务由属于后端进程本身的运行器执行(apps/backend/src/runner/)。每个任务都经过四个阶段:
- 接收。 向
platform."backgroundTaskRun"添加一行,写明公司、任务和幂等键。 - 领取。 运行器获取该行的租约。
- 运行。 为该任务注册的处理程序在绑定到公司的事务中执行。
- 记录。 运行最终的状态为
pending、running、retrying、succeeded或deadLettered之一。
alforse/company-export 之类的任务名和 packages/web/jobs 包保留了这些名称,但它们背后没有外部作业服务。
定时任务
apps/backend/src/runner/native-runner-schedules.ts 声明了调度。其 cron 表达式按 UTC 解读,每次触发都会分发到所有活动公司。
投递有自己的调度:通知中继每分钟运行一次,维护到期派发每两分钟一次,此外还有用于清理 blob 以及备份新增文件的作业。
通知
通知进入每位用户的收件箱。后端发布变更订阅源,例如私有主题user:<userId>:notification;订阅源告诉 ERP 哪些行发生了变化,但不包含其内容,ERP 再获取所需内容。电子邮件通过所配置的任何事务性电子邮件传输发出。
其他引擎
工作流
工作流是客户在拖放画布上自行设计的自动化。不要把它们与运行器自带的任务混淆:
定义由
packages/web/workflows 和 kernel/workflows 中的代码描述并校验。每次运行及其中的每一步都由引擎记录,供日后检查。运行器有两项调度为其提供支持:每十分钟一次的工作流兜底,以及运行历史的保留。该功能本身见工作流。
CAD 与装配规划
这里完成两项工作。STEP 格式的 CAD 文件被转换为浏览器可以显示的 3D 模型,并计算一个避免碰撞的拆卸顺序来驱动装配说明。代码位于后端的apps/backend/src/cad/:occt-import-js(编译为 WebAssembly 的 OpenCASCADE)解析几何,运动规划器与其并列。
两个应用都在 /api/web/assembler 调用它,使用 bearer 密钥认证,并通过 packages/web/viewer 显示输出。耗时较长的转换作为运行器任务执行;调用页面可以等待完成或轮询。
术语表
本页所链接术语的简短定义,按 A 到 Z 排序。Action
路由文件中用于处理已提交表单的导出。它在服务器上执行,绝不会发送到浏览器。Kysely
TypeScript 中的 SQL 查询构建器。在这里它只描述一个操作;后端把该描述转换为命名的原生操作。Lingui
用于翻译的库。代码中标记的字符串会被提取到每种语言一个文件中,供译者填写。Loader
路由文件中用于收集其页面所显示数据的导出。与 action 一样,仅限服务器端。Migration
一个带编号的 SQL 文件,包含一次模式更改。基线加上后续文件按顺序合在一起,就是 模式。Monorepo
一个 Git 仓库,容纳多个应用和共享库,它们统一版本、统一发布。MRP
物料需求计划(Material Requirements Planning)的缩写:根据需求和现有库存,决定要采购或生产什么,以及何时完成。Multi-tenancy
用一个数据库和一个运行中的应用为许多独立的客户公司服务,同时每个公司只能看到自己的数据。Postgres
PostgreSQL,Alforse 所构建于其上的关系型数据库。RLS
行级安全(Row Level Security)。Postgres 自动为每个查询附加每表规则,正是这条规则使各公司无法看到彼此的数据。RPC
在本页中:通过client.rpc("name", args) 从应用代码调用命名函数,后端会把它路由到原生所有者。
Turborepo
通过跟踪各包之间的依赖关系来编排构建,使pnpm build 只重做已变化的部分。