Skip to content

feat(example): add the payment context and close unpaid orders in ddk-mall - #80

Merged
poppycoderr merged 1 commit into
mainfrom
feat/mall-payment
Oct 7, 2026
Merged

poppycoderr merged 1 commit into
mainfrom
feat/mall-payment

Conversation

@poppycoderr

Copy link
Copy Markdown
Owner

Background

ddk-mall could place an order and reserve stock, but the order then stayed in PENDING_PAYMENT forever: nothing could pay it, and nothing closed it if nobody did. This is step 4 of the reference application: a payment context and a job that closes unpaid orders. With it the whole business flow runs end to end.

Changes

Payment context

A third bounded context, com.example.mall.payment, with the same four layers as the other two. It never calls the order context. The amount and the payment deadline arrive in a new order.awaiting-payment message, so a payment is created without looking the order up.

sequenceDiagram
    participant C as Customer
    participant O as Order
    participant I as Inventory
    participant P as Payment
    O-->>P: order.awaiting-payment (amount, deadline)
    P->>P: create payment (PENDING)
    C->>P: POST /payments/orders/{id}/pay
    P->>P: charge the channel, payment PAID
    P-->>O: payment.completed
    O->>O: PAID
    O-->>I: order.paid
    I->>I: deduct the reserved stock
    Note over O: customer cancels, or the job finds the deadline passed
    O-->>I: order.cancelled: release the reservation
    O-->>P: order.cancelled: close the payment, refund if money was taken
Loading
  • The channel is a port. PaymentChannel has charge and refund; SimulatedPaymentChannel approves anything up to mall.payment.simulated.decline-above and declines the rest. Both operations use the payment id as the merchant reference, the way real channels deduplicate, so a retry never charges twice.
  • No distributed transaction around the channel. Saving the payment can fail after the channel has charged. The channel's deduplication plus the payment's status and optimistic lock make sure exactly one request records it as paid.

The order decides

A payment and a cancellation can cross: the customer pays while the order is being cancelled or closed. The order's status settles which one wins, and the other contexts follow it.

markPaid(orderId)                      # on payment.completed
  if order is PENDING_PAYMENT
    order -> PAID, publish order.paid
  else if order is CANCELLED
    publish order.cancelled again      # the payment context refunds

This is the same self-correcting approach confirmStock already uses for a reservation that arrives after a cancellation. That method now checks for CANCELLED explicitly; with the new PAID status, "not open" no longer means "cancelled".

Closing unpaid orders

  • An order gets a deadline when it is placed (mall.order.payment-timeout, 30 minutes by default), stored in a new expires_at column (migration V4).
  • CloseExpiredOrdersJob lives in the adapter layer, runs every mall.order.close-interval and carries @SchedulerLock. ArchitectureTest now also enforces SCHEDULED_JOBS_MUST_BE_LOCKED.
  • Each order is closed in its own transaction. The lock only keeps instances from scanning in parallel; correctness comes from the order's status and optimistic lock.
  • The default profile has no Redis, so LocalJobLock supplies a lock provider that always grants. It is single-instance only, like the in-process stock lock.

Layout

 com.example.mall
 ├── order
-│   ├── adapter/{controller,messaging}
+│   ├── adapter/{controller,messaging,job}
 ├── inventory
+└── payment
+    ├── adapter/{controller,messaging}
+    ├── application/{response,service,handler,integration}
+    ├── domain/{model,event,acl,error}
+    └── infrastructure/{acl/impl,channel,converter,id,orm}

背景

ddk-mall 之前可以下单并预占库存,但订单会一直停在 PENDING_PAYMENT:没有办法支付,也没有人在无人支付时把它关掉。这是参考应用的第 4 步:支付上下文,以及关闭未支付订单的定时任务。完成后整条业务链路可以从头走到尾。

改动

支付上下文

第三个限界上下文 com.example.mall.payment,和另外两个一样是四层结构。它不调用订单上下文:应付金额和支付期限随新增的 order.awaiting-payment 消息带过来,建立支付单时不需要回头查订单。交互过程见上面的时序图。

  • 渠道是一个端口。 PaymentChannel 有 charge 和 refund 两个操作;SimulatedPaymentChannel 对不超过 mall.payment.simulated.decline-above 的金额都放行,超过的拒绝。两个操作都以支付单标识作为商户单号,和真实渠道的去重方式一致,重试不会多扣。
  • 请求渠道不靠分布式事务。 渠道扣款成功之后保存支付单可能失败。渠道的去重,加上支付单的状态和乐观锁,保证只有一个请求把它记成已支付。

订单说了算

付款和取消可能交叉:顾客付款的同时订单被取消或超时关闭。由订单的状态决定哪一个生效,另外两个上下文跟随它(逻辑见上面的伪代码)。订单已经取消时收到「支付已完成」,会再发一次「已取消」,支付上下文据此退款。

这和 confirmStock 处理「取消之后才到的预占」是同一个办法。那个方法现在明确判断 CANCELLED:新增 PAID 状态之后,「不在进行中」不再等于「已取消」。

关闭未支付的订单

  • 订单在下单时得到一个支付期限(mall.order.payment-timeout,默认 30 分钟),存在新增的 expires_at 列里(迁移脚本 V4)。
  • CloseExpiredOrdersJob 放在适配层,每隔 mall.order.close-interval 执行一次,带 @SchedulerLock。ArchitectureTest 新增检查 SCHEDULED_JOBS_MUST_BE_LOCKED。
  • 每个订单在自己的事务里关闭。锁只是避免多个实例同时扫描,正确性来自订单的状态和乐观锁。
  • 默认 profile 没有 Redis,由 LocalJobLock 提供一个总是放行的锁实现。它只适合单实例,和进程内的库存锁一样。

结构

包结构的变化见上面的 diff。

@poppycoderr
poppycoderr merged commit 2e73533 into main Oct 7, 2026
4 checks passed
@poppycoderr
poppycoderr deleted the feat/mall-payment branch October 7, 2026 19:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant