2. 交易领域建模
交易系统的复杂度并不来自“下单”这个动作本身,而来自一次交易被拆成多个领域对象后,它们各自有独立生命周期、又必须保持一致性。领域建模要解决的核心问题是:哪些概念是独立聚合根,哪些只是聚合内的实体或值对象,以及每个聚合根的状态如何流转。如果这一步没做清楚,后续会出现“订单状态和支付状态互相覆盖”“退款单和订单状态不一致”“优惠券核销了但订单取消了”等典型问题。
一、核心领域对象与职责边界
在移动交易场景中,通常至少存在以下核心对象:
- 订单(Order):交易的业务主单,记录用户买了什么、应付多少、收货信息、业务状态。它是用户视角的“这笔交易”。
- 支付单(Payment):一次支付尝试的凭证,绑定支付渠道、渠道流水号、实付金额。一个订单可能对应多次支付尝试(失败重试、换渠道)。
- 退款单(Refund):一次退款请求,绑定原支付单、退款金额、退款渠道流水。一个支付单可对应多笔部分退款。
- 优惠券(Coupon):营销资产,有领取、锁定、核销、释放、过期等状态,与订单是“使用关系”而非从属关系。
- 账户流水(AccountLedger):资金变动的账本记录,只追加不修改,是资金对账的最终依据。
关键判断:订单、支付单、退款单、优惠券、账户流水应各自作为独立聚合根。原因是它们生命周期不同、并发修改点不同、且需要独立查询与对账。若把支付单塞进订单聚合内部,订单聚合会因支付回调频繁加锁,且退款单无法独立演进。它们之间通过ID 引用关联,而不是对象嵌套。
二、聚合根划分原则
划分聚合根时遵循三条可操作原则:
- 一致性边界最小化:一个事务只修改一个聚合根。订单创建时锁定优惠券,应通过领域事件或应用服务协调,而不是在一个事务里同时改订单和优惠券。
- 引用用 ID,不用对象:订单里只存
paymentId、couponId,不持有支付单对象,避免聚合膨胀和并发冲突。 - 状态由本聚合维护:支付单状态只能由支付聚合修改,订单不能直接改支付单状态,只能根据支付结果事件推进自己的状态。
三、状态机设计
状态机的价值在于把“合法流转”显式化,禁止非法跳转。下面给出各核心对象的状态定义与关键流转。
订单状态:CREATED(已创建待支付)→ PAID(已支付)→ FULFILLING(履约中)→ COMPLETED(已完成);CREATED 超时未支付 → CANCELLED(已取消);PAID 后发生退款 → REFUNDING → REFUNDED。注意:订单不因支付失败而改变状态,支付失败只影响支付单,订单仍停留在 CREATED 等待重试或超时。
支付单状态:INIT → PAYING(已发起渠道)→ SUCCESS / FAILED;SUCCESS 后可进入 PARTIAL_REFUNDED / REFUNDED。支付单状态必须由渠道回调驱动,且回调需幂等。
退款单状态:APPLIED → PROCESSING → SUCCESS / FAILED。退款成功后才允许写账户流水。
优惠券状态:AVAILABLE → LOCKED(下单锁定)→ USED(支付成功核销);LOCKED 在订单取消或超时后回到 AVAILABLE;超过有效期进入 EXPIRED。
状态流转必须满足两个工程约束:幂等(同一事件重复到达不产生二次流转)和可追溯(每次流转记录前置状态、事件、时间)。
四、对象关系与状态流转全景
下图把五个聚合根、它们之间的引用关系,以及订单与支付单的状态推进路径放在同一张图里,便于对照理解“谁驱动谁”。
五、落地时的关键实现要点
把上面的模型落到代码时,有三个容易踩坑的点。
第一,状态流转要收敛到聚合根内部方法。不要让外部服务直接 order.setStatus(PAID),而应提供 order.markPaid(paymentId, paidAt),在方法内校验前置状态、记录流转日志、抛出非法流转异常。这样状态机才是“活的”。
第二,跨聚合一致性用领域事件 + 最终一致。支付回调到达后,支付聚合先落库 SUCCESS,再发布 PaymentSucceededEvent;订单聚合消费该事件推进到 PAID,优惠券聚合消费该事件把券从 LOCKED 改为 USED,账本聚合写入正向流水。每个消费者都要幂等,用事件 ID 去重。
第三,金额与状态要能对账。订单应付金额 = 商品金额 − 优惠金额;支付单实付金额应等于订单应付金额;退款单累计退款金额不得超过支付单实付金额。这些约束建议在聚合根方法内校验,而不是只靠数据库字段。
AVAILABLE,若状态寄生在订单上,释放逻辑会变得难以追踪。六、建模成果检查清单
完成本节建模后,可用下表快速自检。表中“示例值”仅用于说明字段形态,不代表真实业务数据。
| 聚合根 | 关键字段(示例) | 状态字段 | 一致性约束 |
|---|---|---|---|
| Order | orderId, userId, payAmount, couponId | status | payAmount ≥ 0;取消前未支付 |
| Payment | paymentId, orderId, channel, channelNo | status | 成功回调幂等;金额等于订单应付 |
| Refund | refundId, paymentId, refundAmount | status | 累计退款 ≤ 实付金额 |
| Coupon | couponId, userId, threshold | status | LOCKED 只能被同一订单核销或释放 |
| AccountLedger | ledgerId, bizId, direction, amount | —(只追加) | 同一 bizId 不重复入账 |
这张表的意义在于:每个聚合根的状态字段和约束一旦明确,后续的接口设计、数据库索引、对账任务和异常补偿都有据可依。领域建模不是画完图就结束,而是让每个状态流转都能被代码校验、被日志追溯、被对账验证。