Core · 开发应用 · 服务端

工作流

工作流基于状态机定义业务记录的状态、允许的流转及其触发条件,适合需要跨越多次请求、持续较长时间,并由人员操作、自动处理或外部系统共同推进的业务流程。例如,订单可以等待用户付款、接收支付回调,再由人员确认发货;等待期间只需保存当前状态,无需持续占用运行中的任务。@nocobase/lifecycle 用代码声明这些规则,应用负责接入存储、业务接口和页面。

下面以示例项目的 Order payment(订单支付) 为例:用户发起付款,系统创建收银台并等待支付回调;付款后可以发货或退款,未付款超时会关闭订单,取消后到账则自动退款。多级审批、会签和加签等人工审批需求见审批插件。

状态与流转

状态表示记录当前所处的阶段,流转表示一次明确的推进,例如发起付款、取消或发货。订单的 status 就是流程状态,不另存一份独立的流程实例状态。流转历史记录操作者、时间和前后状态,自动处理记录则保存提交后的执行情况。

通过 defineLifecycle() 声明初始状态、允许的流转和结束状态。下面摘录 server/lifecycles/order.ts 的定义,省略参数配置等内容,并用 orderTransitions、orderTriggers 代指其流转和超时规则。OrderTypes 与 orderProblems 分别是示例的类型和创建校验函数。

TypeScript
export const orderLifecycle = defineLifecycle<OrderTypes>({
  name: 'orders',
  collection: LIFECYCLE_EXAMPLE_COLLECTIONS.orders,
  initial: 'draft',
  create: { validate: orderProblems },
  states: [
    'draft', 'creatingCheckout', 'awaitingPayment', 'paymentFailed',
    'paid', 'cancelled', 'refunding', 'refundNeedsAttention',
    { name: 'fulfilled', final: true },
    { name: 'refunded', final: true },
  ],
  parameters: { paymentWindowMinutes: 3 },
  transitions: orderTransitions,
  onEnter: { creatingCheckout: [createCheckout], refunding: [refund] },
  triggers: orderTriggers,
});

发起付款后,订单从草稿进入创建收银台,再进入等待付款;支付回调将其推进到已付款,随后可以发货或退款。fulfilled(已发货)和 refunded(已退款)是结束状态。cancelled 不是结束状态,因为取消后仍可能收到付款,此时会进入退款。

使用 runtime.create('orders', values, { actor }) 创建记录,由 runtime 写入初始状态和 $create 历史。actor 来自服务端验证的身份,values 只包含允许创建的业务字段。后续状态变化统一通过流转入口处理,不能由通用更新接口直接写入。

操作、条件与分支

操作对应具名流转。from、to 限定起点和目标,set 更新同事务内的业务字段,accept 保存允许的输入。下面是订单定义中发起付款和收银台创建成功的两个条目:

TypeScript
checkout: {
  from: ['draft', 'paymentFailed'],
  to: 'creatingCheckout',
  set: ({ record }) => ({
    paymentAttempt: record.paymentAttempt + 1,
    paymentSessionId: null,
    checkoutUrl: null,
    lastError: null,
  }),
},
checkoutCreated: {
  manual: false,
  from: 'creatingCheckout',
  to: 'awaitingPayment',
  accept: ['paymentSessionId', 'checkoutUrl'],
},

点击 Pay 发起 checkout,支付轮次增加并清除上一轮收银台信息;自动处理成功后,checkoutCreated 保存支付会话和地址。创建订单时,create.validate 检查客户、订单名称和大于零的金额。通用的 guard、流转 validate 和 route 可用于条件、输入与目标选择;本例的分支由支付结果和当前状态对应的不同流转表达。

支付被拒进入 paymentFailed,可以再次发起付款;付款成功进入 paid,可以选择 ship 发货或 refundOrder 退款。退款失败进入 refundNeedsAttention,由 retryRefund 重新进入退款。

cancel 允许从 draft、awaitingPayment、paymentFailed 取消订单,同时安排关闭收银台。支付回调和自动处理结果对应的流转设置 manual: false,人工接口传入 manual: true,防止用户直接选择支付成功。角色权限与记录范围仍由接口授权检查。

应用接入与权限控制

应用准备订单表、流转历史表和自动处理执行表。订单表除业务字段外,保存 status、statusChangedAt 和 lifecycleVersion,分别用于状态、等待计时和并发检查。包不自动执行迁移,字段与索引按所用版本的存储要求创建;应用接入见数据模型与迁移。

Provider 负责装配共享 runtime、存储与执行器,流程规则仍在定义中。下面的 database、executors 来自应用的 databaseManagerToken、jobExecutorServiceToken;flowServices 是符合示例 FlowServices 接口的服务对象,包含支付沙箱和故障模拟能力:

TypeScript
import {
  LifecycleRuntime,
  createRepositoryLifecycleStore,
} from '@nocobase/lifecycle';
import { createLifecycleJobs } from '@nocobase/lifecycle/jobs';

const jobs = createLifecycleJobs({
  jobs: executors.getJobExecutor('app/orders'),
  schedule: executors.getScheduleExecutor('app/orders'),
  jobName: 'app/orders/effect',
  sweepEveryMs: 5_000,
});

const runtime = new LifecycleRuntime({
  store: createRepositoryLifecycleStore(database),
  dispatcher: jobs,
});
runtime.register(orderLifecycle, { services: flowServices });

将 runtime 通过服务 Token 注册到容器,把 Provider 追加到 server/providers/index.ts。在 start() 中调用 jobs.start(runtime),在 shutdown() 中调用 jobs.shutdown();路由复用同一个 runtime。注册方式见服务端服务,执行后端与多实例配置见后台任务与定时执行。

一次流转将记录变化、历史和待执行工作写入同一事务,外部调用在提交后执行。条件校验需要读取其他表时,通过绑定当前事务的 services 工厂取得连接。事务使用方式见事务与数据一致性。

应用自行提供创建、读取和操作接口。操作入口先认证、授权、校验输入,再调用 runtime:

TypeScript
const result = await runtime.fire('orders', orderId, body.transition, {
  actor: { id: String(signedInUserId) },
  manual: true,
  input: body.input,
  requestId: body.requestId,
  expect: { version: body.expectVersion },
});

const view = await runtime.view('orders', orderId, {
  id: String(signedInUserId),
});
return c.json({ data: { ...view, replayed: result.replayed === true } });

其中 body 是校验后的输入,用户 ID 来自可信会话。接口还要检查记录范围,分别授权读取、流转和异常处理;恢复接口必须核对执行记录属于当前订单。store 不会自动继承请求的权限策略。具体接入见认证、权限与数据范围和请求校验与错误处理。

同一次操作因网络问题重发时复用 requestId,输入变化或新决定使用新标识;重放已成功的操作不会再次流转。expectVersion 传入页面读取的版本,过期操作返回 CONFLICT,应刷新后重新决定。请求标识用于去重,版本用于防止旧决定覆盖新进度。

自动处理、等待与恢复

**自动处理(effect)**是流转提交后要执行的工作。例如进入 creatingCheckout 后创建收银台,成功后通过 checkoutCreated 进入等待付款;重试耗尽则通过 checkoutFailed 进入 paymentFailed。

TypeScript
import { defineEffect } from '@nocobase/lifecycle';

const createCheckout = defineEffect<OrderTypes>({
  name: 'orders.createCheckout',
  retry: { attempts: 3, backoffMs: 2_000, factor: 2, maxMs: 10_000 },
  timeoutMs: 10_000,
  onSuccess: 'checkoutCreated',
  onFailure: 'checkoutFailed',
  async run({ record, services }) {
    if (services.shouldFail(`order-checkout:${record.id}`, record.failCheckouts)) {
      throw new Error('The payment provider is unavailable (simulated).');
    }
    const session = await services.sandbox.createCheckout({
      idempotencyKey: `order-checkout:${record.id}:${record.paymentAttempt}`,
      orderId: String(record.id),
      amountCents: record.amountCents,
    });
    return { paymentSessionId: session.sessionId, checkoutUrl: session.url };
  },
});

services.sandbox.createCheckout 是示例的支付沙箱接口,不会发起真实支付。failCheckouts 用于模拟失败次数;同一支付轮次的重试复用幂等键,用户再次发起付款时才增加 paymentAttempt 并创建新会话。attempts: 3 包括首次尝试。

等待外部结果只需保存状态,不占用持续运行的任务。收银台创建成功不代表已付款,支付服务的回调才决定是否到账。下面展示通过回调推进未取消订单的调用,来源验证由回调接收端完成:

TypeScript
import { SYSTEM_ACTOR } from '@nocobase/lifecycle';

// After verifying the payment callback, for an order still awaiting payment.
await runtime.fire('orders', orderId, 'paymentSucceeded', {
  actor: SYSTEM_ACTOR,
  input: { paymentRef },
  requestId: `payments:${eventId}`,
});

SYSTEM_ACTOR 不负责验证外部来源,接收端先检查签名和事件格式。同一事件重投使用同一标识;paymentSucceeded 允许从 creatingCheckout 或 awaitingPayment 进入 paid,因此也能处理早于收银台创建结果到达的付款。订单已取消时,接收端改用 paidAfterCancel 推进到退款。数据库错误和并发冲突仍需重投处理。

等待超时通过 trigger 推进。示例默认等待 3 分钟;以下 orderTriggers 条目在 awaitingPayment 或 paymentFailed 停留超时后触发 expire,将订单转为 cancelled:

TypeScript
expireUnpaid: {
  transition: 'expire',
  when: ['awaitingPayment', 'paymentFailed'],
  after: ({ paymentWindowMinutes }) => paymentWindowMinutes * 60_000,
},

超时检查间隔应短于等待期限,到期记录在检查时被发现。expire 保存超时原因并安排关闭收银台;若支付已经发生,只是回调迟到,后续通过 paidAfterCancel 自动退款。

失败恢复先区分外部工作是否已完成:

情况处理方式
创建收银台暂时失败按策略重试,同一支付轮次复用幂等键
创建收银台重试耗尽进入 paymentFailed,用户可再次点击 Pay
退款重试耗尽进入 refundNeedsAttention,点击 Retry refund 后重新退款;按订单和付款引用去重
其他符合条件的失败执行授权后调用 runtime.retryRun(runId);遇到 RUN_SETTLED 不默认强制重跑
外部处理已成功,后续流转受阻解除条件后调用 runtime.continueRun(runId),应用已有结果,不重发外部请求
应用重启或执行中断Jobs 适配器与定时检查恢复符合条件的工作

持久化存储保留待执行工作和已取得的结果;恢复可能再次调用外部服务,因此仍需幂等。保持流程、effect 和任务名称稳定,让新进程识别旧记录;执行错误接入日志,按保留策略清理可删除的记录。

进度与处理记录

页面沿用应用已有的 shadcn 组件,通过 runtime.view() 对应的接口取得记录、版本、候选操作和历史。客户端可使用:

TypeScript
import { useApiClient } from '@nocobase/app-client';
import { createLifecycleHook } from '@nocobase/lifecycle/react';

export const useOrderLifecycle = createLifecycleHook({
  useTransport: useApiClient,
  basePath: 'lifecycleExample',
});

// Inside the order detail component:
const { view, busy, fire } = useOrderLifecycle('orders', orderId);

此配置要求应用在 API 前缀下实现 GET /lifecycleExample/orders/lifecycle、GET /lifecycleExample/orders/:id 和 POST /lifecycleExample/orders/:id/fire,以 { data } 返回结果;Hook 不会生成接口。

服务端按用户权限过滤或标记 view.available,页面根据 allowed、blockers 展示按钮及原因,提交时使用 busy。例如 Pay 调用 fire('checkout'),Retry refund 调用 fire('retryRefund');字段错误显示在表单旁,版本冲突提示刷新。接口仍需再次授权。

详情页分别显示当前阶段、流转时间线和自动处理结果,包括尝试次数、错误及待继续的流转。Hook 默认定时刷新,便于看到外部结果。系统设置中的 Workflow management 可集中提供定义与执行记录,并限制重试、继续等处理权限。

流程图复用模板或 UI Library 的 LifecycleDiagram,传入定义、当前状态和实际历史。图形用于查看,不提供可视化流程设计;点击图形不能绕过操作接口推进流程。

订单支付工作流
订单支付工作流

示例与验证

在示例项目打开 Order payment,新建订单后点击 Pay,再在 Payment provider (sandbox) 中模拟付款或拒付。Demo options 可设置收银台或退款故意失败的次数;Hold the webhook 暂存回调,Deliver 投递,Deliver again 重投。

操作应观察到的结果
发起付款,在沙箱付款后点击 Ship等待付款 → 已付款 → 已发货
将收银台失败次数设为 2,再发起付款第三次尝试成功,进入等待付款
等待付款超过期限超时关闭,订单进入已取消
暂存付款回调,取消订单后再投递已取消 → 退款中 → 已退款
将退款失败次数设为 3,付款后退款进入退款待处理;点击 Retry refund 后可完成退款
重复投递同一支付回调显示 Replayed,不重复推进订单

支付、退款和回调均由示例沙箱模拟,不涉及真实资金。权限、并发、事务回滚和重启恢复仍需应用集成测试,见测试。

实现对照:订单流程与 effects、回调接收、恢复测试。

NocoBase 文档助手

关于 NocoBase,你想了解什么?

每次提问独立回答,不会参考之前的问答。请提供完整背景。