Microservice event choreography 与 UI interaction 如何协作?
结论
不要让 Payment Service 把“会员资料已提交”事件当作支付指令。这个事件只说明一件已经发生的事,并不具备执行支付所需的全部条件。
更合适的职责划分如下:
Membership Service接收注册资料,并创建状态为AWAITING_PAYMENT的会员申请。- UI 根据服务端返回的状态或
nextAction显示支付表单。 - 用户提交支付信息后,UI 向
Payment Service发送明确的支付命令。 Payment Service完成支付后,发布PaymentSucceeded或PaymentFailed。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 命令负责表达用户当前要求系统执行的动作。
重点不是让所有步骤都由事件触发,而是为每次交互选择符合其含义的方式。需要用户输入的环节自然会暂停异步流程,把它建模为明确的等待状态,正是这个设计中容易遗漏的一点。
备注:内容仅供参考。
