PAYATHON 2026

Microservice event choreography 与 UI interaction 如何协作?

支付小周

结论

不要让 Payment Service 把“会员资料已提交”事件当作支付指令。这个事件只说明一件已经发生的事,并不具备执行支付所需的全部条件。

更合适的职责划分如下:

  1. Membership Service 接收注册资料,并创建状态为 AWAITING_PAYMENT 的会员申请。
  2. UI 根据服务端返回的状态或 nextAction 显示支付表单。
  3. 用户提交支付信息后,UI 向 Payment Service 发送明确的支付命令。
  4. Payment Service 完成支付后,发布 PaymentSucceeded 或 PaymentFailed。
  5. Membership Service 消费支付结果事件,并更新会员状态。

UI 负责推进需要用户输入的交互环节,后端负责维护业务状态和执行业务规则。它们处理的是同一流程的不同层面,不属于重复实现。

事件不等于下一步命令

这三种概念很容易混在一起:

  • 事件表示“某件事已经发生”。
  • 命令表示“请执行某个动作”。
  • UI 请求通常也是一种命令。

例如:

MembershipApplicationSubmitted

它表示会员申请已经提交,不应暗含以下指令:

ChargeCustomer

申请提交时,支付金额、支付方式和支付授权等条件可能还没有准备齐全。

如果 Payment Service 收到事件后无法执行任何操作,只能继续等待另一份数据,那么通常没有必要建立这个订阅关系。否则,系统会多出没有实际作用的中间状态、事件关联和超时处理,也容易让人误以为支付已经开始。

推荐流程

整个流程可以这样设计:

UI
 │
 │ 1. POST /membership-applications
 ▼
Membership Service
 │
 │ 创建申请,状态为 AWAITING_PAYMENT
 │ 返回 applicationId、amount、nextAction
 ▼
UI
 │
 │ 2. 显示支付表单并采集支付授权
 │
 │ 3. POST /payments
 ▼
Payment Service
 │
 │ 执行支付
 │ 发布 PaymentSucceeded / PaymentFailed
 ▼
Membership Service
 │
 │ 更新申请状态
 ▼
UI 查询或订阅最终状态

这个流程没有向 Payment Service 发送重复通知:

  • Membership Service 不会在申请提交时要求它开始支付。
  • UI 只在支付信息齐备后发送一次支付命令。
  • 支付完成事件表达的是支付结果,不是第二次支付请求。

API 示例

创建会员申请:

POST /membership-applications
Content-Type: application/json
Idempotency-Key: signup-8d3d3ab1

{
  "name": "Zhang San",
  "email": "zhangsan@example.com",
  "planId": "premium-yearly"
}

服务端返回:

HTTP/1.1 201 Created
Content-Type: application/json
Location: /membership-applications/app_123

{
  "applicationId": "app_123",
  "status": "AWAITING_PAYMENT",
  "amount": {
    "currency": "CNY",
    "value": "299.00"
  },
  "nextAction": {
    "type": "COLLECT_PAYMENT",
    "paymentIntentId": "pi_456"
  }
}

UI 不应只依赖写死在本地的步骤来决定下一页,至少还要参考后端返回的业务状态。这样,如果服务端以后加入人工审核、资格校验或免支付方案,前端不必重新实现所有判断规则。

用户录入支付信息后:

POST /payments
Content-Type: application/json
Idempotency-Key: payment-app_123

{
  "paymentIntentId": "pi_456",
  "membershipApplicationId": "app_123",
  "paymentMethodToken": "pm_token_789"
}

支付成功后,Payment Service 发布事件:

{
  "eventId": "evt_101",
  "eventType": "PaymentSucceeded",
  "occurredAt": "2026-09-01T08:30:00Z",
  "paymentId": "pay_987",
  "paymentIntentId": "pi_456",
  "membershipApplicationId": "app_123",
  "amount": {
    "currency": "CNY",
    "value": "299.00"
  }
}

Membership Service 消费该事件,将申请状态从:

AWAITING_PAYMENT

更新为:

ACTIVE

如果支付失败,则处理 PaymentFailed,同时保留重新支付的入口。

UI 是否在编排业务流程

UI 的确控制着页面的交互顺序,但不应掌握核心业务规则。

UI 可以负责:

  • 显示注册表单。
  • 根据服务端状态显示下一步操作。
  • 收集用户输入。
  • 调用对应 API。
  • 显示处理中、成功或失败状态。

后端应该负责:

  • 判断申请是否有效。
  • 计算应付金额。
  • 判断是否需要支付、审核或补充资料。
  • 确保未支付的会员不能被激活。
  • 校验状态转换是否合法。
  • 处理重试、重复请求、超时和异步结果。

例如,前端可以显示支付按钮,但服务端仍需校验当前是否允许创建支付:

application.status == AWAITING_PAYMENT

前端流程负责用户体验层面的导航,不能作为业务一致性的最终保障。

流程复杂时引入 Saga 或 Process Manager

如果流程只有“创建申请 → 用户付款 → 激活会员”,使用状态字段、语义明确的 API 命令和支付结果事件就足够了。

如果流程还包括:

  • 身份验证;
  • 人工审核;
  • 优惠资格计算;
  • 支付授权;
  • 合同签署;
  • 多次重试;
  • 超时取消;
  • 补偿操作;

仅靠 choreography,可能会形成多个服务互相订阅事件,却没有任何组件能够清楚说明流程当前进行到了哪一步。

此时可以增加一个 Signup Process Manager 或 Saga orchestrator:

Signup Process Manager
  ├── 记录流程当前状态
  ├── 决定当前允许的操作
  ├── 向 UI 提供 nextAction
  ├── 发送后端命令
  ├── 消费各服务的结果事件
  └── 处理超时、失败和补偿

例如:

{
  "processId": "signup_123",
  "status": "WAITING_FOR_PAYMENT",
  "nextActions": [
    {
      "type": "SUBMIT_PAYMENT",
      "paymentIntentId": "pi_456"
    }
  ]
}

orchestrator 不应接管各个服务内部的领域逻辑。它管理跨服务流程,Membership Service 和 Payment Service 仍分别负责会员规则和支付规则。

UI 如何获取异步状态

支付和会员激活可能异步完成。UI 可以通过以下方式获取状态:

  • 轮询 GET /membership-applications/{id};
  • 使用 WebSocket 或 Server-Sent Events;
  • 通过 BFF 聚合会员申请和支付状态;
  • 流程较复杂时,查询 Process Manager 的状态。

例如:

GET /membership-applications/app_123

响应:

{
  "applicationId": "app_123",
  "status": "ACTIVE",
  "paymentStatus": "SUCCEEDED",
  "nextAction": null
}

不要让浏览器直接消费内部消息总线中的领域事件。内部事件的格式、重试机制和安全边界通常不适合直接作为公共 UI 接口。

如果希望 Payment Service 提前创建支付

还有一种可行的方案:Payment Service 消费会员申请事件,但只创建支付意图,不直接扣款。

MembershipApplicationAccepted
        │
        ▼
Payment Service 创建 PaymentIntent
        │
        ▼
PaymentIntentCreated

UI 随后获取 paymentIntentId,提交支付方式并确认支付:

POST /payment-intents/pi_456/confirm

这种方案适用于会员申请完成后,支付金额和收款对象已经确定,而且创建支付意图本身具有业务价值的情况。

这里同样不存在重复支付:

  • 事件触发 PaymentIntent 创建。
  • UI 请求触发支付确认。
  • 两个动作的语义不同,需要分别使用不同的状态和幂等键。

如果事件到达时,Payment Service 既无法创建有效状态,也不能执行有意义的操作,就不应该订阅该事件。

注意事项

不要把支付敏感信息放入领域事件

银行卡号、CVV 等敏感信息不应通过普通事件总线传播。通常应使用支付服务商提供的安全组件进行 tokenization,业务系统只接收类似以下内容:

paymentMethodToken

具体处理方式取决于支付服务商以及适用的安全和合规要求。

所有可能重试的命令都要支持幂等

HTTP 请求和消息消费都有可能重复。支付接口尤其需要使用稳定的幂等键,例如:

payment:{membershipApplicationId}

Payment Service 还应根据 eventId、paymentIntentId 或业务唯一键去重,不能假设消息只会投递一次。

用关联标识串联整个流程

建议在 API 和事件中携带:

membershipApplicationId
paymentIntentId
correlationId
causationId
eventId

这些标识可以用来追踪一次注册从创建申请、支付到激活会员的完整链路。

后端必须校验状态转换

即使 UI 按正确顺序调用接口,后端也要拒绝非法转换,例如:

ACTIVE -> AWAITING_PAYMENT
CANCELLED -> ACTIVE
PaymentIntent(SUCCEEDED) -> confirm again

流程是否正确不能依赖前端页面的执行顺序。

最终职责边界

可以这样概括:

  • UI 负责“现在需要用户做什么”。
  • Process Manager 或服务端状态负责“业务流程现在处于哪一步”。
  • Membership Service 负责会员申请和会员状态。
  • Payment Service 负责支付意图、支付执行和支付结果。
  • 事件负责传播已经发生的事实。
  • API 命令负责表达用户当前要求系统执行的动作。

重点不是让所有步骤都由事件触发,而是为每次交互选择符合其含义的方式。需要用户输入的环节自然会暂停异步流程,把它建模为明确的等待状态,正是这个设计中容易遗漏的一点。

备注:内容仅供参考。